Skip to content

Commit f024082

Browse files
committed
add legba module, add rust to shared_deps
1 parent d7885f8 commit f024082

File tree

3 files changed

+392
-0
lines changed

3 files changed

+392
-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: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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 = ["FINDING"]
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",
45+
"ftp_wordlist": "Wordlist URL for FTP combined username:password wordlist, newline separated",
46+
"telnet_wordlist": "Wordlist URL for TELNET combined username:password wordlist, newline separated",
47+
"vnc_wordlist": "Wordlist URL for VNC password wordlist, newline separated",
48+
"mssql_wordlist": "Wordlist URL for MSSQL combined username:password wordlist, newline separated",
49+
"mysql_wordlist": "Wordlist URL for MySQL combined username:password wordlist, newline separated",
50+
"postgresql_wordlist": "Wordlist URL for PostgreSQL combined username:password wordlist, newline separated",
51+
"concurrency": "Number of concurrent workers, gets overridden for SSH",
52+
"rate_limit": "Limit the number of requests per second, gets overridden for SSH",
53+
}
54+
55+
deps_common = ["rust"]
56+
deps_ansible = [
57+
{
58+
"name": "Install dev tools (Debian)",
59+
"package": {
60+
"name": ["pkg-config", "cmake", "libclang-dev", "clang"],
61+
"state": "present",
62+
},
63+
"become": True,
64+
"when": "ansible_facts['os_family'] == 'Debian'",
65+
"ignore_errors": True,
66+
},
67+
{
68+
"name": "Install dev tools (Fedora)",
69+
"package": {
70+
"name": ["pkgconf-pkg-config", "cmake", "clang-devel", "llvm-devel", "perl-core"],
71+
"state": "present",
72+
},
73+
"become": True,
74+
"when": "ansible_facts['os_family'] == 'RedHat'",
75+
"ignore_errors": True,
76+
},
77+
{
78+
"name": "Install dev tools (Arch)",
79+
"package": {
80+
"name": ["pkgconf", "cmake", "clang", "openssl"],
81+
"state": "present",
82+
},
83+
"become": True,
84+
"when": "ansible_facts['os_family'] == 'Archlinux'",
85+
"ignore_errors": True,
86+
},
87+
{
88+
"name": "Get legba repo",
89+
"git": {
90+
"repo": "https://github.com/evilsocket/legba",
91+
"dest": "#{BBOT_TEMP}/legba/gitrepo",
92+
"version": "1.1.1", # Newest stable, 2025-08-25
93+
},
94+
},
95+
{
96+
# The git repo will be copied because during build, files and subfolders get created. That prevents the Ansible git module to cache the repo.
97+
"name": "Copy legba repo",
98+
"copy": {
99+
"src": "#{BBOT_TEMP}/legba/gitrepo/",
100+
"dest": "#{BBOT_TEMP}/legba/workdir/",
101+
},
102+
},
103+
{
104+
"name": "Build legba",
105+
"command": {
106+
"chdir": "#{BBOT_TEMP}/legba/workdir",
107+
"cmd": "cargo build --release",
108+
"creates": "#{BBOT_TEMP}/legba/workdir/target/release/legba",
109+
},
110+
"environment": {"PATH": "{{ ansible_env.PATH }}:{{ ansible_env.HOME }}/.cargo/bin"},
111+
},
112+
{
113+
"name": "Install legba",
114+
"copy": {
115+
"src": "#{BBOT_TEMP}/legba/workdir/target/release/legba",
116+
"dest": "#{BBOT_TOOLS}/",
117+
"mode": "u+x,g+x,o+x",
118+
},
119+
},
120+
]
121+
122+
async def setup(self):
123+
self.output_dir = Path(self.scan.temp_dir / "legba-output")
124+
self.helpers.mkdir(self.output_dir)
125+
126+
return True
127+
128+
async def filter_event(self, event):
129+
handled_protocols = ["ssh", "ftp", "telnet", "vnc", "mssql", "mysql", "postgresql"]
130+
131+
protocol = event.data["protocol"].lower()
132+
if not protocol in handled_protocols:
133+
return False, f"service {protocol} is currently not supported or can't be bruteforced by Legba"
134+
135+
return True
136+
137+
async def handle_event(self, event):
138+
host = str(event.host)
139+
port = str(event.port)
140+
protocol = event.data["protocol"].lower()
141+
142+
command_data = await self.construct_command(host, port, protocol)
143+
144+
if not command_data:
145+
self.warning(f"Skipping {host}:{port} ({protocol}) due to errors while constructing the command")
146+
return
147+
148+
command, output_path = command_data
149+
150+
await self.run_process(command)
151+
152+
async for finding_event in self.parse_output(output_path, event):
153+
await self.emit_event(finding_event)
154+
155+
async def parse_output(self, output_filepath, event):
156+
protocol = event.data["protocol"].lower()
157+
158+
try:
159+
with open(output_filepath) as file:
160+
for line in file:
161+
# example line (ssh):
162+
# {"found_at":"2025-07-18T06:28:08.969812152+01:00","target":"localhost:22","plugin":"ssh","data":{"username":"user","password":"pass"},"partial":false}
163+
line = line.strip()
164+
165+
try:
166+
data = json.loads(line)["data"]
167+
username = data.get("username", "")
168+
password = data.get("password", "")
169+
170+
if username and password:
171+
message_addition = f"{username}:{password}"
172+
elif username:
173+
message_addition = username
174+
elif password:
175+
message_addition = password
176+
except Exception as e:
177+
self.warning(f"Failed to parse Legba output ({line}), using raw output instead: {e}")
178+
message_addition = f"raw output: {line}"
179+
180+
yield self.make_event(
181+
{
182+
"severity": "CRITICAL",
183+
"confidence": "CONFIRMED",
184+
"host": str(event.host),
185+
"port": str(event.port),
186+
"description": f"Valid {protocol} credentials found - {message_addition}",
187+
},
188+
"FINDING",
189+
parent=event,
190+
)
191+
except FileNotFoundError:
192+
self.info(
193+
f"Could not open Legba output file {output_filepath}. File is missing if no valid credentials could be found"
194+
)
195+
except Exception as e:
196+
self.warning(f"Error processing Legba output file {output_filepath}: {e}")
197+
else:
198+
self.helpers.delete_file(output_filepath)
199+
200+
async def construct_command(self, host, port, protocol):
201+
# -C Combo wordlist delimited by ':'
202+
# -P Passwordlist
203+
# --target Target (allowed: host, url, IP address, CIDR, @filename)
204+
# --output-format Output file format
205+
# --output Save results to this file
206+
# -Q Do not report statistics
207+
#
208+
# --wait Wait time in milliseconds per login attempt
209+
# --rate-limit Limit the number of requests per second
210+
# --concurrency Number of concurrent workers
211+
212+
# Example command to bruteforce SSH:
213+
#
214+
# 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
215+
216+
try:
217+
wordlist_path = await self.helpers.wordlist(self.config.get(f"{protocol}_wordlist"))
218+
except WordlistError as e:
219+
self.warning(f"Error retrieving wordlist for protocol {protocol}: {e}")
220+
return None
221+
except Exception as e:
222+
self.warning(f"Unexpected error during wordlist loading for protocol {protocol}: {e}")
223+
return None
224+
225+
protocol_plugin_name = map_protocol_to_legba_plugin_name(protocol)
226+
output_path = Path(self.output_dir) / f"{host}_{port}.json"
227+
228+
cmd = [
229+
"legba",
230+
protocol_plugin_name,
231+
]
232+
233+
if protocol == "vnc":
234+
# use only passwords, not combinations
235+
cmd += ["-P"]
236+
237+
else:
238+
# use combinations
239+
cmd += ["-C"]
240+
241+
# wrap IPv6 addresses in square brackets
242+
if self.helpers.is_ip(host, version=6):
243+
host = f"[{host}]"
244+
245+
cmd += [
246+
wordlist_path,
247+
"--target",
248+
f"{host}:{port}",
249+
"--output-format",
250+
"jsonl",
251+
"--output",
252+
output_path,
253+
"-Q",
254+
]
255+
256+
if protocol == "ssh":
257+
# With OpenSSH 9.8, the sshd_config option "PerSourcePenalties" was introduced (on by default)
258+
# The penalty "authfail" defaults to 5 seconds, so bruteforcing fast will block access.
259+
# 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.
260+
# See https://www.openssh.com/txt/release-9.8
261+
cmd += [
262+
"--wait",
263+
"5000",
264+
"--rate-limit",
265+
"1",
266+
"--concurrency",
267+
"1",
268+
]
269+
else:
270+
cmd += ["--rate-limit", self.config.rate_limit, "--concurrency", self.config.concurrency]
271+
272+
return cmd, output_path

0 commit comments

Comments
 (0)