Skip to content

Commit b1600da

Browse files
committed
add legba module, add rust to shared_deps
1 parent 386034f commit b1600da

File tree

2 files changed

+251
-0
lines changed

2 files changed

+251
-0
lines changed

bbot/core/shared_deps.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,26 @@
244244
},
245245
]
246246

247+
DEP_RUST = [
248+
{
249+
"name": "Check if Rust is installed",
250+
"command": "which rustc",
251+
"register": "rust_installed",
252+
"ignore_errors": True,
253+
},
254+
{
255+
"name": "Download Rust Installer",
256+
"get_url": {
257+
"url": "https://sh.rustup.rs",
258+
"dest": "/tmp/sh.rustup.rs",
259+
"mode": "0755",
260+
"force": "yes",
261+
},
262+
"when": "rust_installed.rc != 0",
263+
},
264+
{"name": "Install Rust", "command": "/tmp/sh.rustup.rs -y", "when": "rust_installed.rc != 0"},
265+
]
266+
247267
# shared module dependencies -- ffuf, massdns, chromium, etc.
248268
SHARED_DEPS = {}
249269
for var, val in list(locals().items()):

bbot/modules/deadly/legba.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import json
2+
from pathlib import Path
3+
from bbot.errors import WordlistError
4+
from bbot.modules.base import BaseModule
5+
6+
# key: <common-protocol-name> value: <legba-protocol-plugin-name>
7+
# List with `legba -L`
8+
PROTOCOL_LEGBA_PLUGIN_MAP = {
9+
"postgresql": "pgsql",
10+
}
11+
12+
13+
# Maps common protocol names to Legba protocol plugin names
14+
def map_protocol_to_legba_plugin_name(common_protocol_name: str) -> str:
15+
return PROTOCOL_LEGBA_PLUGIN_MAP.get(common_protocol_name, common_protocol_name)
16+
17+
18+
class legba(BaseModule):
19+
watched_events = ["PROTOCOL"]
20+
produced_events = ["VULNERABILITY"]
21+
flags = ["active", "aggressive", "deadly"]
22+
per_hostport_only = True
23+
meta = {
24+
"description": "Credential bruteforcing supporting various services.",
25+
"created_date": "2025-07-18",
26+
"author": "@christianfl",
27+
}
28+
_module_threads = 25
29+
scope_distance_modifier = None
30+
31+
options = {
32+
"ssh_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/ssh-betterdefaultpasslist.txt",
33+
"ftp_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/ftp-betterdefaultpasslist.txt",
34+
"telnet_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/telnet-betterdefaultpasslist.txt",
35+
"vnc_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/vnc-betterdefaultpasslist.txt",
36+
"mssql_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/mssql-betterdefaultpasslist.txt",
37+
"mysql_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/mysql-betterdefaultpasslist.txt",
38+
"postgresql_wordlist": "https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/postgres-betterdefaultpasslist.txt",
39+
"concurrency": 3,
40+
"rate_limit": 3,
41+
}
42+
43+
options_desc = {
44+
"ssh_wordlist": "Wordlist URL for SSH combined username:password wordlist, newline separated (default https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/ssh-betterdefaultpasslist.txt)",
45+
"ftp_wordlist": "Wordlist URL for FTP combined username:password wordlist, newline separated (default https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/ftp-betterdefaultpasslist.txt)",
46+
"telnet_wordlist": "Wordlist URL for TELNET combined username:password wordlist, newline separated (default https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/telnet-betterdefaultpasslist.txt)",
47+
"vnc_wordlist": "Wordlist URL for VNC combined username:password wordlist, newline separated (default https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/vnc-betterdefaultpasslist.txt)",
48+
"mssql_wordlist": "Wordlist URL for MSSQL combined username:password wordlist, newline separated (default https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/mssql-betterdefaultpasslist.txt)",
49+
"mysql_wordlist": "Wordlist URL for MySQL combined username:password wordlist, newline separated (default https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/mysql-betterdefaultpasslist.txt)",
50+
"postgresql_wordlist": "Wordlist URL for PostgreSQL combined username:password wordlist, newline separated (default https://raw.githubusercontent.com/danielmiessler/SecLists/refs/heads/master/Passwords/Default-Credentials/postgres-betterdefaultpasslist.txt)",
51+
"concurrency": "Number of concurrent workers, gets overridden for SSH (default 3)",
52+
"rate_limit": "Limit the number of requests per second, gets overridden for SSH (default 3)",
53+
}
54+
55+
deps_common = ["rust"]
56+
deps_ansible = [
57+
{
58+
"name": "Install dev tools (Debian)",
59+
"package": {
60+
"name": ["libssl-dev", "libsmbclient-dev", "pkg-config", "cmake"],
61+
"state": "present",
62+
},
63+
"become": True,
64+
"when": "ansible_facts['distribution'] == 'Debian'",
65+
"ignore_errors": True,
66+
},
67+
{
68+
"name": "Get legba repo",
69+
"git": {
70+
"repo": "https://github.com/evilsocket/legba",
71+
"dest": "#{BBOT_TEMP}/legba",
72+
"version": "v0.11.0", # Newest stable, 2025-07-18
73+
},
74+
},
75+
{
76+
"name": "Build legba",
77+
"command": {
78+
"chdir": "#{BBOT_TEMP}/legba",
79+
"cmd": "cargo build --release --features http_relative_paths",
80+
"creates": "#{BBOT_TEMP}/legba/target/release/legba",
81+
},
82+
"environment": {"PATH": "{{ ansible_env.PATH }}:{{ ansible_env.HOME }}/.cargo/bin", "RUST_BACKTRACE": "1"},
83+
},
84+
{
85+
"name": "Install legba",
86+
"copy": {
87+
"src": "#{BBOT_TEMP}/legba/target/release/legba",
88+
"dest": "#{BBOT_TOOLS}/",
89+
"mode": "u+x,g+x,o+x",
90+
},
91+
},
92+
]
93+
94+
async def setup(self):
95+
self.output_dir = "/tmp/legba-output"
96+
self.helpers.mkdir(self.output_dir)
97+
98+
return True
99+
100+
async def filter_event(self, event):
101+
handled_protocols = ["ssh", "ftp", "mssql", "mysql", "postgresql", "telnet", "vnc"]
102+
103+
protocol = event.data["protocol"].lower()
104+
if not protocol in handled_protocols:
105+
return False, f"service {protocol} is currently not supported or can't be bruteforced by Legba"
106+
107+
return True
108+
109+
async def handle_event(self, event):
110+
host = str(event.host)
111+
port = str(event.port)
112+
protocol = event.data["protocol"].lower()
113+
114+
command_data = await self.construct_command(host, port, protocol)
115+
116+
if not command_data:
117+
self.warning(f"Skipping {host}:{port} ({protocol}) due to errors while constructing the command")
118+
return
119+
120+
command, output_path = command_data
121+
122+
await self.run_process(command)
123+
124+
async for new_vuln_event in self.parse_output(output_path, event):
125+
await self.emit_event(new_vuln_event)
126+
127+
async def parse_output(self, output_filepath, event):
128+
protocol = event.data["protocol"].lower()
129+
130+
try:
131+
with open(output_filepath) as file:
132+
for line in file:
133+
# example line (ssh):
134+
# {"found_at":"2025-07-18T06:28:08.969812152+01:00","target":"localhost:22","plugin":"ssh","data":{"username":"user","password":"pass"},"partial":false}
135+
line = line.strip()
136+
137+
try:
138+
data = json.loads(line)["data"]
139+
username = data.get("username", "")
140+
password = data.get("password", "")
141+
142+
message_addition = f"{username}:{password}"
143+
except Exception as e:
144+
self.warning(f"Failed to parse Legba output ({line}), using raw output instead: {e}")
145+
message_addition = f"raw output: {line}"
146+
147+
yield self.create_vuln_event(
148+
"CRITICAL",
149+
f"Valid {protocol} credentials found - {message_addition}",
150+
event,
151+
)
152+
except FileNotFoundError:
153+
self.warning(f"Could not open Legba output file {output_filepath}")
154+
except Exception as e:
155+
self.warning(f"Error processing Legba output file {output_filepath}: {e}")
156+
else:
157+
self.helpers.delete_file(output_filepath)
158+
159+
async def construct_command(self, host, port, protocol):
160+
# -C Combo wordlist delimited by ':'
161+
# --target Target (allowed: host, url, IP address, CIDR, @filename)
162+
# --output-format Output file format
163+
# --output Save results to this file
164+
# -Q Do not report statistics
165+
#
166+
# --wait Wait time in milliseconds per login attempt
167+
# --rate-limit Limit the number of requests per second
168+
# --concurrency Number of concurrent workers
169+
170+
# Example command to bruteforce SSH:
171+
#
172+
# legba ssh -C combolist.txt --target 127.0.0.1:22 --output-format jsonl --output out.txt -Q --wait 4000 --rate-limit 1 --concurrency 1
173+
174+
try:
175+
wordlist_path = await self.helpers.wordlist(self.config.get(f"{protocol}_wordlist"))
176+
except WordlistError as e:
177+
self.warning(f"Error retrieving wordlist for protocol {protocol}: {e}")
178+
return None
179+
except Exception as e:
180+
self.warning(f"Unexpected error during wordlist loading for protocol {protocol}: {e}")
181+
return None
182+
183+
protocol_plugin_name = map_protocol_to_legba_plugin_name(protocol)
184+
output_path = Path(self.output_dir) / f"{host}_{port}.json"
185+
186+
cmd = [
187+
"legba",
188+
protocol_plugin_name,
189+
"-C",
190+
wordlist_path,
191+
"--target",
192+
f"{host}:{port}",
193+
"--output-format",
194+
"jsonl",
195+
"--output",
196+
output_path,
197+
"-Q",
198+
]
199+
200+
if protocol == "ssh":
201+
# With OpenSSH 9.8, the sshd_config option "PerSourcePenalties" was introduced (on by default)
202+
# The penalty "authfail" defaults to 5 seconds, so bruteforcing fast will block access.
203+
# Legba is not able to check that by itself, so the wait time is set to 5 s, rate limit to 1 and concurrency to 1 with SSH.
204+
# See https://www.openssh.com/txt/release-9.8
205+
cmd += [
206+
"--wait",
207+
"5000",
208+
"--rate-limit",
209+
"1",
210+
"--concurrency",
211+
"1",
212+
]
213+
else:
214+
cmd += ["--rate-limit", self.config.rate_limit, "--concurrency", self.config.concurrency]
215+
216+
return cmd, output_path
217+
218+
def create_vuln_event(self, severity, description, source_event):
219+
host = str(source_event.host)
220+
port = int(source_event.port)
221+
222+
return self.make_event(
223+
{
224+
"severity": severity,
225+
"host": host,
226+
"port": port,
227+
"description": description,
228+
},
229+
"VULNERABILITY",
230+
source_event,
231+
)

0 commit comments

Comments
 (0)