Files
rpcscope/tests/test_batch_execution.py

269 lines
16 KiB
Python

"""Mocked full scans: no sockets, external scanners or target traffic."""
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from rpcscope.catalog import EXPOSURE_CHECKS, resolve_checks
from rpcscope.checks import CheckResult
from rpcscope.netexec import NxcCapabilities
from rpcscope.reporting import coverage
from rpcscope.runner import ScanConfig, scan
TOOLS = {"nxc": "fake-nxc", "enum4linux-ng": "fake-enum", "rpcdump": "fake-rpc", "msfconsole": None}
CURRENT = NxcCapabilities.from_help("--shares --file-write-check --no-admin-check")
def targets(command):
return command[2:next(i for i, arg in enumerate(command) if arg.startswith("-u="))]
def banner(address):
return f"SMB {address} 445 HOST [*] Windows Server 2022 (name:HOST) (domain:LAB)"
def alive(addresses, on_result, **kwargs):
for address in addresses:
on_result(address, {135: "open", 445: "open"})
class BatchExecutionTests(unittest.TestCase):
def test_rpcdump_exit_zero_requires_reliable_inventory_and_no_protocol_error(self):
endpoint = "UUID: 12345678-1234-abcd-ef00-0123456789ab v1.0\nBindings:\n ncacn_ip_tcp:192.0.2.1[50000]\n"
cases = [
("[-] Protocol failed: access denied\n[*] No endpoints found.\n", "failed", True),
("CRITICAL:root:Protocol failed: refused\n", "failed", True),
("", "failed", True),
("[*] Received one endpoint.\n", "failed", True),
(endpoint + "[-] Protocol failed: incomplete lookup\n", "failed", True),
("[*] No endpoints found.\n", "success", False),
(endpoint + "Provider: ModuleNotFoundError: remote annotation\n", "success", False),
]
for text, status, has_errors in cases:
with self.subTest(text=text), tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), use_enum4linux=False, use_rpcdump=True)
def execute(command, timeout, log, password, **kwargs):
return (0, text) if command[0] == "fake-rpc" else (0, banner("192.0.2.1"))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute):
host = scan(config)["192.0.2.1"]
self.assertEqual(host.collection["rpcdump"], status)
self.assertEqual(bool(config.errors), has_errors)
self.assertEqual(coverage(host)[2], has_errors)
if endpoint in text:
self.assertTrue(host.rpc_interfaces) # keep partial evidence
def test_local_rpcdump_dependency_error_stops_queued_copies_not_other_checks(self):
addresses = ["192.0.2.1", "192.0.2.2", "192.0.2.3"]
calls = []
def execute(command, timeout, log, password, **kwargs):
calls.append(command)
if command[0] == "fake-rpc":
return 1, "Traceback (most recent call last):\nModuleNotFoundError: No module named 'impacket'"
if "-M" in command:
return 0, f"MS17-010 {command[2]} 445 HOST {command[2]} does NOT appear vulnerable"
return 0, "\n".join(banner(address) for address in addresses)
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(addresses, out_dir=Path(temporary), jobs=1, use_enum4linux=False,
use_rpcdump=True, checks=resolve_checks(["ms17-010"]))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute):
hosts = scan(config)
self.assertEqual(sum(command[0] == "fake-rpc" for command in calls), 1)
self.assertEqual(sum("-M" in command for command in calls), 3)
self.assertEqual(hosts[addresses[0]].collection["rpcdump"], "failed")
self.assertEqual(hosts[addresses[1]].collection["rpcdump"], "skipped-local-error")
self.assertTrue(all(host.checks["ms17-010"] == "not-vulnerable" for host in hosts.values()))
self.assertIn("ModuleNotFoundError", config.errors[0])
self.assertTrue(any(item["stage"] == "rpcdump" and item["exit_code"] == 1 for item in config.timings))
def test_remote_rpcdump_failure_does_not_disable_other_targets(self):
addresses = ["192.0.2.1", "192.0.2.2"]
def execute(command, timeout, log, password, **kwargs):
return (1, "RPC access denied") if command[0] == "fake-rpc" else (0, "\n".join(banner(address) for address in addresses))
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(addresses, out_dir=Path(temporary), jobs=1, use_enum4linux=False, use_rpcdump=True)
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute):
hosts = scan(config)
self.assertTrue(all(host.collection["rpcdump"] == "failed" for host in hosts.values()))
self.assertEqual(len(config.errors), 2)
def test_duplicate_api_checks_are_executed_and_counted_once(self):
definition = resolve_checks(["ms17-010"])[0]
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True,
use_enum4linux=False, use_rpcdump=False, checks=[definition, definition])
def execute(command, timeout, log, password, **kwargs):
return (0, "MS17-010 192.0.2.1 445 HOST VULNERABLE") if "-M" in command else (0, banner("192.0.2.1"))
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run:
hosts = scan(config, progress=events.append)
self.assertEqual(run.call_count, 2)
self.assertEqual(len(hosts["192.0.2.1"].check_attempts), 1)
self.assertEqual(events[-1].completed, events[-1].total)
def test_full_enumeration_does_not_delay_dedicated_checks(self):
order = []
def execute(command, timeout, log, password, **kwargs):
if command[0] == "fake-enum":
order.append("enum")
prefix = command[command.index("-oJ") + 1]
Path(prefix + ".json").write_text('{"target":{"host":"192.0.2.1"}}', encoding="utf-8")
return 0, ""
if "-M" in command:
order.append("check")
return 0, "MS17-010 192.0.2.1 445 HOST VULNERABLE"
order.append("discovery")
return 0, banner("192.0.2.1")
def native(*args, **kwargs):
order.append("native")
return CheckResult("inconclusive", "no response")
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True,
use_enum4linux=True, use_rpcdump=False, checks=resolve_checks(["ms17-010,longlogon"]))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute), \
patch("rpcscope.runner.probe_longlogon", side_effect=native):
hosts = scan(config)
self.assertEqual(order, ["discovery", "check", "native", "enum"])
self.assertEqual(hosts["192.0.2.1"].collection["enum4linux"], "success")
def test_local_discovery_failure_is_retained_as_failure_not_dead_scan_success(self):
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), tcp_discovery=True, batch_checks=True,
checks=resolve_checks(["recommended"]))
events = []
with patch("rpcscope.runner.doctor", return_value=TOOLS), \
patch("rpcscope.runner.discover_services", side_effect=OSError("local socket resources exhausted")), \
patch("rpcscope.runner._run") as run:
hosts = scan(config, progress=events.append)
run.assert_not_called()
self.assertFalse(config.cancelled)
self.assertEqual(events[-1].status, "incomplete")
self.assertEqual(hosts["192.0.2.1"].services["445"], "unprobed")
self.assertEqual(hosts["192.0.2.1"].checks, {})
self.assertTrue(any("failed locally" in message for message in config.errors))
def test_recommended_32_host_scan_uses_24_processes_with_full_attribution(self):
addresses = [f"192.0.2.{i}" for i in range(1, 33)]
commands = []
def execute(command, timeout, log, password, **kwargs):
commands.append(command)
if "-M" not in command:
return 0, "\n".join(banner(address) for address in targets(command))
self.assertLessEqual(len(targets(command)), 8)
self.assertLessEqual(int(command[command.index("-t") + 1]), 8)
self.assertIn("--no-admin-check", command)
module = command[command.index("-M") + 1]
lines = []
for address in targets(command):
messages = {
"ms17-010": [("MS17-010", f"{address} does NOT appear vulnerable")],
"zerologon": [], # no recognized output remains UNKNOWN
"printnightmare": [("PRINTNIGHT...", "Not vulnerable :'(")],
"smbghost": [("SMBGHOST", "Potentially vulnerable to SMBGhost (CVE-2020-0796)")],
"coerce_plus": [("COERCE_PLUS", "VULNERABLE, " + method) for method in
("PetitPotam", "DFSCoerce", "ShadowCoerce", "PrinterBug", "MSEven")],
"spooler": [("SPOOLER", "Spooler service enabled")],
"ioxidresolver": [("IOXIDRES...", "Address: 10.0.0.1")],
}[module]
lines.extend(f"{name} {address} 445 HOST {message}" for name, message in messages)
self.assertEqual(timeout, 600 if module == "coerce_plus" else 120)
return 0, "\n".join(lines)
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(addresses, out_dir=Path(temporary), tcp_discovery=True, batch_checks=True,
use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["recommended"]))
events, snapshots = [], []
with patch("rpcscope.runner.doctor", return_value=TOOLS), \
patch("rpcscope.runner.inspect_capabilities", return_value=CURRENT), \
patch("rpcscope.runner.discover_services", side_effect=alive), \
patch("rpcscope.runner._run", side_effect=execute), \
patch("rpcscope.runner.probe_longlogon", return_value=CheckResult("precondition-not-met", "not patch detection")), \
patch("rpcscope.runner.probe_smb_compression", return_value=CheckResult("exposed", "compression capability only")):
hosts = scan(config, progress=events.append, checkpoint=lambda value: snapshots.append(len(value)))
check_commands = [command for command in commands if "-M" in command]
self.assertEqual(len(check_commands), 24)
self.assertEqual(len(commands), 25) # one separate inventory invocation
self.assertEqual(len(hosts), 32)
self.assertGreater(len(snapshots), 1)
for host in hosts.values():
self.assertEqual(len(host.check_attempts), 12)
self.assertEqual(coverage(host), (12, 12, False))
self.assertEqual(host.checks["zerologon"], "inconclusive")
self.assertEqual(host.checks["ms17-010"], "not-vulnerable")
self.assertEqual(host.checks["smbghost"], "exposed")
self.assertEqual(host.checks["oxid"], "observed")
self.assertTrue(all(host.checks[key] == "exposed" for key in EXPOSURE_CHECKS))
self.assertEqual(config.errors, [])
self.assertEqual(events[-1].status, "complete")
self.assertEqual(events[-1].completed, events[-1].total)
def test_zero_exit_host_exception_does_not_downgrade_other_hosts(self):
addresses = ["192.0.2.1", "192.0.2.2"]
def execute(command, timeout, log, password, **kwargs):
if "-M" not in command:
return 0, "\n".join(banner(address) for address in addresses)
return 0, ("MS17-010 192.0.2.1 445 HOST VULNERABLE\n"
"Exception while calling proto_flow() on target 192.0.2.2: failure\n"
"Traceback (most recent call last):\nValueError: malformed response\n")
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(addresses, out_dir=Path(temporary), batch_checks=True,
use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["ms17-010"]))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute):
hosts = scan(config)
self.assertEqual(hosts[addresses[0]].checks["ms17-010"], "vulnerable")
self.assertFalse(coverage(hosts[addresses[0]])[2])
self.assertEqual(hosts[addresses[1]].checks["ms17-010"], "inconclusive")
self.assertTrue(coverage(hosts[addresses[1]])[2])
self.assertTrue(config.errors)
def test_failed_batch_is_not_retried_or_mistaken_for_negative(self):
def execute(command, timeout, log, password, **kwargs):
return (124, "MS17-010 192.0.2.1 445 HOST VULNERABLE") if "-M" in command else (0, banner("192.0.2.1"))
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True,
use_enum4linux=False, use_rpcdump=False, checks=resolve_checks(["ms17-010"]))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run:
hosts = scan(config)
self.assertEqual(run.call_count, 2)
self.assertEqual(hosts["192.0.2.1"].checks["ms17-010"], "inconclusive")
self.assertTrue(coverage(hosts["192.0.2.1"])[2])
def test_batch_cancellation_retains_finished_evidence_and_stops_remaining_work(self):
def execute(command, timeout, log, password, cancel, **kwargs):
if "-M" in command:
cancel.set()
return 130, ""
return 0, banner("192.0.2.1")
with tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), batch_checks=True,
checks=resolve_checks(["recommended"]))
with patch("rpcscope.runner.doctor", return_value=TOOLS), patch("rpcscope.runner._run", side_effect=execute) as run, \
patch("rpcscope.runner.probe_longlogon") as native:
hosts = scan(config)
native.assert_not_called()
self.assertEqual(run.call_count, 2)
self.assertTrue(config.cancelled)
self.assertNotIn("pending", hosts["192.0.2.1"].checks.values())
self.assertEqual(hosts["192.0.2.1"].checks["longlogon"], "skipped-cancelled")
def test_share_inventory_uses_only_verified_readonly_options(self):
for caps, expected in ((CURRENT, ["--shares"]),
(NxcCapabilities.from_help("--shares --no-write-check"), ["--shares", "--no-write-check"]),
(NxcCapabilities(), [])):
with self.subTest(flags=caps.flags), tempfile.TemporaryDirectory() as temporary:
config = ScanConfig(["192.0.2.1"], out_dir=Path(temporary), shares=True,
use_enum4linux=False, use_rpcdump=False)
with patch("rpcscope.runner.doctor", return_value=TOOLS), \
patch("rpcscope.runner.inspect_capabilities", return_value=caps), \
patch("rpcscope.runner._run", return_value=(0, banner("192.0.2.1"))) as run:
hosts = scan(config)
command = run.call_args.args[0]
self.assertEqual([arg for arg in command if arg in {"--shares", "--no-write-check"}], expected)
self.assertNotIn("--file-write-check", command)
self.assertEqual(bool(config.errors), not bool(expected))
if not expected:
self.assertEqual(hosts["192.0.2.1"].collection["shares"], "skipped-unsupported")
if __name__ == "__main__":
unittest.main()