Skip to content

Commit 5670238

Browse files
Merge pull request #2864 from blacklanternsecurity/add_module_legba
Add legba module for bruteforcing various services
2 parents 0aedc74 + 377e9f1 commit 5670238

10 files changed

Lines changed: 357 additions & 9 deletions

File tree

bbot/core/helpers/misc.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2088,14 +2088,34 @@ def cpu_architecture():
20882088
import platform
20892089

20902090
uname = platform.uname()
2091-
arch = uname.machine.lower()
2091+
return uname.machine.lower()
2092+
2093+
2094+
def cpu_architecture_golang():
2095+
"""
2096+
CPU architecture for GoLang release binaries.
2097+
"""
2098+
arch = cpu_architecture()
2099+
# golang uses "arm64" instead of "aarch64"
20922100
if arch.startswith("aarch"):
20932101
return "arm64"
2094-
elif arch == "x86_64":
2102+
# golang uses "amd64" instead of "x86_64"
2103+
if arch == "x86_64":
20952104
return "amd64"
20962105
return arch
20972106

20982107

2108+
def cpu_architecture_rust():
2109+
"""
2110+
CPU architecture for Rust release binaries.
2111+
"""
2112+
arch = cpu_architecture()
2113+
# rust uses "arm64" instead of "aarch64"
2114+
if arch.startswith("aarch"):
2115+
return "arm64"
2116+
return arch
2117+
2118+
20992119
def os_platform():
21002120
"""Return the OS platform of the current system.
21012121

bbot/core/shared_deps.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"name": "Download ffuf",
44
"unarchive": {
5-
"src": "https://github.com/ffuf/ffuf/releases/download/v#{BBOT_DEPS_FFUF_VERSION}/ffuf_#{BBOT_DEPS_FFUF_VERSION}_#{BBOT_OS}_#{BBOT_CPU_ARCH}.tar.gz",
5+
"src": "https://github.com/ffuf/ffuf/releases/download/v#{BBOT_DEPS_FFUF_VERSION}/ffuf_#{BBOT_DEPS_FFUF_VERSION}_#{BBOT_OS}_#{BBOT_CPU_ARCH_GOLANG}.tar.gz",
66
"include": "ffuf",
77
"dest": "#{BBOT_TOOLS}",
88
"remote_src": True,

bbot/modules/deadly/legba.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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, @fuzikowski",
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+
"version": "1.1.1",
42+
}
43+
44+
options_desc = {
45+
"ssh_wordlist": "Wordlist URL for SSH combined username:password wordlist, newline separated",
46+
"ftp_wordlist": "Wordlist URL for FTP combined username:password wordlist, newline separated",
47+
"telnet_wordlist": "Wordlist URL for TELNET combined username:password wordlist, newline separated",
48+
"vnc_wordlist": "Wordlist URL for VNC password wordlist, newline separated",
49+
"mssql_wordlist": "Wordlist URL for MSSQL combined username:password wordlist, newline separated",
50+
"mysql_wordlist": "Wordlist URL for MySQL combined username:password wordlist, newline separated",
51+
"postgresql_wordlist": "Wordlist URL for PostgreSQL combined username:password wordlist, newline separated",
52+
"concurrency": "Number of concurrent workers, gets overridden for SSH",
53+
"rate_limit": "Limit the number of requests per second, gets overridden for SSH",
54+
"version": "legba version",
55+
}
56+
57+
deps_ansible = [
58+
{
59+
"name": "Download legba",
60+
"unarchive": {
61+
"src": "https://github.com/evilsocket/legba/releases/download/#{BBOT_MODULES_LEGBA_VERSION}/legba-#{BBOT_MODULES_LEGBA_VERSION}-#{BBOT_OS}-#{BBOT_CPU_ARCH_RUST}.tar.gz",
62+
"dest": "#{BBOT_TEMP}",
63+
"include": "legba-#{BBOT_MODULES_LEGBA_VERSION}-#{BBOT_OS}-#{BBOT_CPU_ARCH_RUST}/legba",
64+
"remote_src": True,
65+
"mode": "u+x,g+x,o+x",
66+
},
67+
}
68+
]
69+
70+
async def setup(self):
71+
self.output_dir = Path(self.scan.temp_dir / "legba-output")
72+
self.helpers.mkdir(self.output_dir)
73+
74+
return True
75+
76+
async def filter_event(self, event):
77+
handled_protocols = ["ssh", "ftp", "telnet", "vnc", "mssql", "mysql", "postgresql"]
78+
79+
protocol = event.data["protocol"].lower()
80+
if not protocol in handled_protocols:
81+
return False, f"service {protocol} is currently not supported or can't be bruteforced by Legba"
82+
83+
return True
84+
85+
async def handle_event(self, event):
86+
host = str(event.host)
87+
port = str(event.port)
88+
protocol = event.data["protocol"].lower()
89+
90+
command_data = await self.construct_command(host, port, protocol)
91+
92+
if not command_data:
93+
self.warning(f"Skipping {host}:{port} ({protocol}) due to errors while constructing the command")
94+
return
95+
96+
command, output_path = command_data
97+
98+
await self.run_process(command)
99+
100+
async for finding_event in self.parse_output(output_path, event):
101+
await self.emit_event(finding_event)
102+
103+
async def parse_output(self, output_filepath, event):
104+
protocol = event.data["protocol"].lower()
105+
106+
try:
107+
with open(output_filepath) as file:
108+
for line in file:
109+
# example line (ssh):
110+
# {"found_at":"2025-07-18T06:28:08.969812152+01:00","target":"localhost:22","plugin":"ssh","data":{"username":"user","password":"pass"},"partial":false}
111+
line = line.strip()
112+
113+
try:
114+
data = json.loads(line)["data"]
115+
username = data.get("username", "")
116+
password = data.get("password", "")
117+
118+
if username and password:
119+
message_addition = f"{username}:{password}"
120+
elif username:
121+
message_addition = username
122+
elif password:
123+
message_addition = password
124+
except Exception as e:
125+
self.warning(f"Failed to parse Legba output ({line}), using raw output instead: {e}")
126+
message_addition = f"raw output: {line}"
127+
128+
yield self.make_event(
129+
{
130+
"severity": "CRITICAL",
131+
"confidence": "CONFIRMED",
132+
"host": str(event.host),
133+
"port": str(event.port),
134+
"description": f"Valid {protocol} credentials found - {message_addition}",
135+
},
136+
"FINDING",
137+
parent=event,
138+
)
139+
except FileNotFoundError:
140+
self.info(
141+
f"Could not open Legba output file {output_filepath}. File is missing if no valid credentials could be found"
142+
)
143+
except Exception as e:
144+
self.warning(f"Error processing Legba output file {output_filepath}: {e}")
145+
else:
146+
self.helpers.delete_file(output_filepath)
147+
148+
async def construct_command(self, host, port, protocol):
149+
# -C Combo wordlist delimited by ':'
150+
# -P Passwordlist
151+
# --target Target (allowed: host, url, IP address, CIDR, @filename)
152+
# --output-format Output file format
153+
# --output Save results to this file
154+
# -Q Do not report statistics
155+
#
156+
# --wait Wait time in milliseconds per login attempt
157+
# --rate-limit Limit the number of requests per second
158+
# --concurrency Number of concurrent workers
159+
160+
# Example command to bruteforce SSH:
161+
#
162+
# 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
163+
164+
try:
165+
wordlist_path = await self.helpers.wordlist(self.config.get(f"{protocol}_wordlist"))
166+
except WordlistError as e:
167+
self.warning(f"Error retrieving wordlist for protocol {protocol}: {e}")
168+
return None
169+
except Exception as e:
170+
self.warning(f"Unexpected error during wordlist loading for protocol {protocol}: {e}")
171+
return None
172+
173+
protocol_plugin_name = map_protocol_to_legba_plugin_name(protocol)
174+
output_path = Path(self.output_dir) / f"{host}_{port}.json"
175+
176+
cmd = [
177+
"legba",
178+
protocol_plugin_name,
179+
]
180+
181+
if protocol == "vnc":
182+
# use only passwords, not combinations
183+
cmd += ["-P"]
184+
185+
else:
186+
# use combinations
187+
cmd += ["-C"]
188+
189+
# wrap IPv6 addresses in square brackets
190+
if self.helpers.is_ip(host, version=6):
191+
host = f"[{host}]"
192+
193+
cmd += [
194+
wordlist_path,
195+
"--target",
196+
f"{host}:{port}",
197+
"--output-format",
198+
"jsonl",
199+
"--output",
200+
output_path,
201+
"-Q",
202+
]
203+
204+
if protocol == "ssh":
205+
# With OpenSSH 9.8, the sshd_config option "PerSourcePenalties" was introduced (on by default)
206+
# The penalty "authfail" defaults to 5 seconds, so bruteforcing fast will block access.
207+
# 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.
208+
# See https://www.openssh.com/txt/release-9.8
209+
cmd += [
210+
"--wait",
211+
"5000",
212+
"--rate-limit",
213+
"1",
214+
"--concurrency",
215+
"1",
216+
]
217+
else:
218+
cmd += ["--rate-limit", self.config.rate_limit, "--concurrency", self.config.concurrency]
219+
220+
return cmd, output_path

bbot/modules/fingerprintx.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class fingerprintx(BaseModule):
2525
{
2626
"name": "Download fingerprintx",
2727
"unarchive": {
28-
"src": "https://github.com/praetorian-inc/fingerprintx/releases/download/v#{BBOT_MODULES_FINGERPRINTX_VERSION}/fingerprintx_#{BBOT_MODULES_FINGERPRINTX_VERSION}_#{BBOT_OS_PLATFORM}_#{BBOT_CPU_ARCH}.tar.gz",
28+
"src": "https://github.com/praetorian-inc/fingerprintx/releases/download/v#{BBOT_MODULES_FINGERPRINTX_VERSION}/fingerprintx_#{BBOT_MODULES_FINGERPRINTX_VERSION}_#{BBOT_OS_PLATFORM}_#{BBOT_CPU_ARCH_GOLANG}.tar.gz",
2929
"include": "fingerprintx",
3030
"dest": "#{BBOT_TOOLS}",
3131
"remote_src": True,

bbot/modules/gowitness.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class gowitness(BaseModule):
4343
{
4444
"name": "Download gowitness",
4545
"get_url": {
46-
"url": "https://github.com/sensepost/gowitness/releases/download/#{BBOT_MODULES_GOWITNESS_VERSION}/gowitness-#{BBOT_MODULES_GOWITNESS_VERSION}-#{BBOT_OS_PLATFORM}-#{BBOT_CPU_ARCH}",
46+
"url": "https://github.com/sensepost/gowitness/releases/download/#{BBOT_MODULES_GOWITNESS_VERSION}/gowitness-#{BBOT_MODULES_GOWITNESS_VERSION}-#{BBOT_OS_PLATFORM}-#{BBOT_CPU_ARCH_GOLANG}",
4747
"dest": "#{BBOT_TOOLS}/gowitness",
4848
"mode": "755",
4949
},

bbot/modules/httpx.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class httpx(BaseModule):
3838
{
3939
"name": "Download httpx",
4040
"unarchive": {
41-
"src": "https://github.com/projectdiscovery/httpx/releases/download/v#{BBOT_MODULES_HTTPX_VERSION}/httpx_#{BBOT_MODULES_HTTPX_VERSION}_#{BBOT_OS}_#{BBOT_CPU_ARCH}.zip",
41+
"src": "https://github.com/projectdiscovery/httpx/releases/download/v#{BBOT_MODULES_HTTPX_VERSION}/httpx_#{BBOT_MODULES_HTTPX_VERSION}_#{BBOT_OS}_#{BBOT_CPU_ARCH_GOLANG}.zip",
4242
"include": "httpx",
4343
"dest": "#{BBOT_TOOLS}",
4444
"remote_src": True,

bbot/modules/nuclei.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class nuclei(BaseModule):
5050
{
5151
"name": "Download nuclei",
5252
"unarchive": {
53-
"src": "https://github.com/projectdiscovery/nuclei/releases/download/v#{BBOT_MODULES_NUCLEI_VERSION}/nuclei_#{BBOT_MODULES_NUCLEI_VERSION}_#{BBOT_OS}_#{BBOT_CPU_ARCH}.zip",
53+
"src": "https://github.com/projectdiscovery/nuclei/releases/download/v#{BBOT_MODULES_NUCLEI_VERSION}/nuclei_#{BBOT_MODULES_NUCLEI_VERSION}_#{BBOT_OS}_#{BBOT_CPU_ARCH_GOLANG}.zip",
5454
"include": "nuclei",
5555
"dest": "#{BBOT_TOOLS}",
5656
"remote_src": True,

bbot/modules/trufflehog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class trufflehog(BaseModule):
3131
{
3232
"name": "Download trufflehog",
3333
"unarchive": {
34-
"src": "https://github.com/trufflesecurity/trufflehog/releases/download/v#{BBOT_MODULES_TRUFFLEHOG_VERSION}/trufflehog_#{BBOT_MODULES_TRUFFLEHOG_VERSION}_#{BBOT_OS_PLATFORM}_#{BBOT_CPU_ARCH}.tar.gz",
34+
"src": "https://github.com/trufflesecurity/trufflehog/releases/download/v#{BBOT_MODULES_TRUFFLEHOG_VERSION}/trufflehog_#{BBOT_MODULES_TRUFFLEHOG_VERSION}_#{BBOT_OS_PLATFORM}_#{BBOT_CPU_ARCH_GOLANG}.tar.gz",
3535
"include": "trufflehog",
3636
"dest": "#{BBOT_TOOLS}",
3737
"remote_src": True,

bbot/scanner/preset/environ.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
import omegaconf
44
from pathlib import Path
55

6-
from bbot.core.helpers.misc import cpu_architecture, os_platform, os_platform_friendly
6+
from bbot.core.helpers.misc import (
7+
cpu_architecture,
8+
cpu_architecture_golang,
9+
cpu_architecture_rust,
10+
os_platform,
11+
os_platform_friendly,
12+
)
713

814

915
REQUESTS_PATCHED = False
@@ -103,6 +109,8 @@ def prepare(self):
103109
environ["BBOT_OS_PLATFORM"] = os_platform()
104110
environ["BBOT_OS"] = os_platform_friendly()
105111
environ["BBOT_CPU_ARCH"] = cpu_architecture()
112+
environ["BBOT_CPU_ARCH_GOLANG"] = cpu_architecture_golang()
113+
environ["BBOT_CPU_ARCH_RUST"] = cpu_architecture_rust()
106114

107115
# copy config to environment
108116
bbot_environ = self.flatten_config(self.preset.config)

0 commit comments

Comments
 (0)