From e98ebaad2afb69512ff43d4b13fa855227a0358a Mon Sep 17 00:00:00 2001 From: Thomas Q Brady Date: Mon, 19 Oct 2026 15:13:47 -0400 Subject: [PATCH] fix(network-ops): redact credentials from tool logs and traces (ENG-8835) Network-ops tools pass passwords, NT hashes, Kerberos AES keys and PFX passwords as command-line arguments, exposing them in execute() logs and in tool-call trace telemetry. - Thread each tool call's credential values into execute(secrets=...) so they are masked in the logged command line and in the returned/raised output. - Recover and mask credentials carried in opaque `args` lists (certipy, netexec) and in the per-tool logger.info argument lines. - Mask the password in smb_list_files, which runs smbclient directly. - Add --add-computer / --computer-pass / -pfx-password to the relay listener's logged-command redaction. Rebased onto the ENG-8834 auth-vocabulary changes (nt_hash / aes_key / ccache). Requires the SDK execute() `secrets` parameter (dreadnode-tiger, same ticket). --- .../network-ops/tests/test_tool_fixes.py | 93 ++++++++++++++++++- capabilities/network-ops/tools/_relay.py | 15 ++- capabilities/network-ops/tools/bloodyad.py | 24 +++++ capabilities/network-ops/tools/certipy.py | 66 +++++++++++-- capabilities/network-ops/tools/coercion.py | 15 ++- capabilities/network-ops/tools/impacket.py | 27 +++++- capabilities/network-ops/tools/krbrelayx.py | 3 + capabilities/network-ops/tools/netexec.py | 24 ++++- capabilities/network-ops/tools/smbclient.py | 34 ++++--- 9 files changed, 260 insertions(+), 41 deletions(-) diff --git a/capabilities/network-ops/tests/test_tool_fixes.py b/capabilities/network-ops/tests/test_tool_fixes.py index d0d5370..e64d838 100644 --- a/capabilities/network-ops/tests/test_tool_fixes.py +++ b/capabilities/network-ops/tests/test_tool_fixes.py @@ -124,6 +124,7 @@ def _load_module(name: str, filename: str): krbrelayx_mod = _load_module("krbrelayx", "krbrelayx.py") netexec_mod = _load_module("netexec", "netexec.py") coercion_mod = _load_module("coercion", "coercion.py") +smbclient_mod = _load_module("smbclient", "smbclient.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") @@ -1709,9 +1710,7 @@ def test_no_legacy_auth_param_names_in_any_method(self): params = inspect.signature(attr).parameters except (TypeError, ValueError): continue - offenders.extend( - f"{cls_name}.{name}({banned})" for banned in self._BANNED if banned in params - ) + offenders.extend(f"{cls_name}.{name}({banned})" for banned in self._BANNED if banned in params) assert not offenders, f"legacy auth param names remain: {offenders}" def test_netexec_kerberos_is_bool_with_aes_key_and_ccache(self): @@ -1724,3 +1723,91 @@ def test_certipy_uses_dc_ip_not_target(self): params = inspect.signature(certipy_mod.Certipy.certipy).parameters assert "dc_ip" in params assert "target" not in params + + +# =================================================================== +# Credential redaction: secrets= threaded into execute() +# =================================================================== + + +def _secrets_of(mock_exec): + """The secrets= list passed to a patched execute().""" + return mock_exec.call_args.kwargs.get("secrets") + + +class TestCredentialRedaction: + """Spot-checks that each threading style carries the credential into + execute(secrets=...): named auth params, a list-valued param, an opaque + arg list (flag recovery), and embedded operation secrets.""" + + @pytest.mark.asyncio + async def test_bloodyad_password_and_nt_hash_threaded(self): + bad = bloodyad_mod.BloodyAD() + with patch.object(bloodyad_mod, "execute", new_callable=AsyncMock) as mock_exec: + await bad.bloodyad_get_object( + "CN=alice", domain="corp.local", username="alice", dc_ip="dc01", password="hunter2" + ) + assert "hunter2" in _secrets_of(mock_exec) + with patch.object(bloodyad_mod, "execute", new_callable=AsyncMock) as mock_exec: + await bad.bloodyad_get_object( + "CN=alice", domain="corp.local", username="alice", dc_ip="dc01", nt_hash="aad3b:5f4" + ) + assert "aad3b:5f4" in _secrets_of(mock_exec) + + @pytest.mark.asyncio + async def test_netexec_named_and_args_embedded_secrets(self): + nxc = netexec_mod.Netexec() + with patch.object(netexec_mod, "execute", new_callable=AsyncMock) as mock_exec: + await nxc.netexec( + protocol="smb", + targets=["10.0.0.1"], + args=["-M", "spider", "-p", "argpw"], + username="alice", + password="hunter2", + ) + secrets = _secrets_of(mock_exec) + assert "hunter2" in secrets + assert "argpw" in secrets + + @pytest.mark.asyncio + async def test_certipy_named_and_opaque_args_secrets(self): + c = certipy_mod.Certipy() + c.certipy_cmd = "certipy" + c.timeout = 10 + with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec: + await c.certipy(action="find", args=[], dc_ip="10.0.0.1", username="alice", password="hunter2") + assert "hunter2" in _secrets_of(mock_exec) + with patch.object(certipy_mod, "execute", new_callable=AsyncMock) as mock_exec: + await c.certipy_auth(args=["-aes", "deadbeefkey", "-pfx-password", "pfxpw"]) + secrets = _secrets_of(mock_exec) + assert "deadbeefkey" in secrets + assert "pfxpw" in secrets + + @pytest.mark.asyncio + async def test_impacket_secretsdump_threads_password_and_aes_key(self): + imp = impacket_mod.Impacket() + imp.timeout = 120 + with ( + patch.object(impacket_mod, "execute", new_callable=AsyncMock) as mock_exec, + patch.object(imp, "_build_script_command", return_value=["secretsdump.py"]), + ): + await imp.impacket_secretsdump(target="10.0.0.1", username="alice", password="hunter2", aes_key="deadbeef") + secrets = _secrets_of(mock_exec) + assert "hunter2" in secrets + assert "deadbeef" in secrets + + @pytest.mark.asyncio + async def test_smbclient_password_threaded(self): + smb = smbclient_mod.SmbClient() + with ( + patch.object(smbclient_mod, "execute", new_callable=AsyncMock) as mock_exec, + patch.object(smbclient_mod, "_require_smbclient", return_value="smbclient"), + ): + await smb.smb_download_file( + target="10.0.0.1", + share_name="C$", + remote_path="f.txt", + username="alice", + password="hunter2", + ) + assert "hunter2" in _secrets_of(mock_exec) diff --git a/capabilities/network-ops/tools/_relay.py b/capabilities/network-ops/tools/_relay.py index 2dfb18e..f991013 100644 --- a/capabilities/network-ops/tools/_relay.py +++ b/capabilities/network-ops/tools/_relay.py @@ -36,7 +36,20 @@ ] # argv flags whose following value is a credential and must not be logged. -_SECRET_FLAGS = {"-p", "--password", "-hashes", "--hashes", "-aeskey", "--aeskey", "-aesKey"} +# ``--add-computer`` carries a "NAME PASSWORD" value (ntlmrelayx); the others +# cover the identity/operation secrets relay tools accept. +_SECRET_FLAGS = { + "-p", + "--password", + "-hashes", + "--hashes", + "-aeskey", + "--aeskey", + "-aesKey", + "--add-computer", + "-pfx-password", + "--computer-pass", +} # How long to wait after SIGTERM before escalating to SIGKILL. _SIGKILL_TIMEOUT = 5.0 diff --git a/capabilities/network-ops/tools/bloodyad.py b/capabilities/network-ops/tools/bloodyad.py index 8244319..cfadd36 100644 --- a/capabilities/network-ops/tools/bloodyad.py +++ b/capabilities/network-ops/tools/bloodyad.py @@ -157,6 +157,7 @@ async def bloodyad_get_object( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -224,6 +225,7 @@ async def bloodyad_get_children( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -285,6 +287,7 @@ async def bloodyad_get_membership( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -341,6 +344,7 @@ async def bloodyad_get_writable( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -395,6 +399,7 @@ async def bloodyad_get_dnsdump( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -449,6 +454,7 @@ async def bloodyad_get_trusts( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -516,6 +522,7 @@ async def bloodyad_get_search( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -574,6 +581,7 @@ async def bloodyad_add_groupmember( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -635,6 +643,7 @@ async def bloodyad_add_computer( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, computer_password) if s], ) @tool_method(catch=True) @@ -696,6 +705,7 @@ async def bloodyad_add_user( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, user_password) if s], ) @tool_method(catch=True) @@ -752,6 +762,7 @@ async def bloodyad_add_dcsync( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -810,6 +821,7 @@ async def bloodyad_add_rbcd( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -871,6 +883,7 @@ async def bloodyad_add_shadowcredentials( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, pfx_password) if s], ) @tool_method(catch=True) @@ -929,6 +942,7 @@ async def bloodyad_add_genericall( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -987,6 +1001,7 @@ async def bloodyad_add_dnsrecord( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -1045,6 +1060,7 @@ async def bloodyad_set_password( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, new_password) if s], ) @tool_method(catch=True) @@ -1106,6 +1122,7 @@ async def bloodyad_set_object( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -1164,6 +1181,7 @@ async def bloodyad_remove_groupmember( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -1220,6 +1238,7 @@ async def bloodyad_remove_object( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -1276,6 +1295,7 @@ async def bloodyad_remove_dcsync( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -1334,6 +1354,7 @@ async def bloodyad_remove_rbcd( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -1390,6 +1411,7 @@ async def bloodyad_remove_shadowcredentials( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -1448,6 +1470,7 @@ async def bloodyad_remove_dnsrecord( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -1506,4 +1529,5 @@ async def bloodyad_remove_genericall( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) diff --git a/capabilities/network-ops/tools/certipy.py b/capabilities/network-ops/tools/certipy.py index 61216d7..2fe3c4f 100644 --- a/capabilities/network-ops/tools/certipy.py +++ b/capabilities/network-ops/tools/certipy.py @@ -9,6 +9,17 @@ from ._relay import stream_listener +# certipy credential flags. A flag that carries a secret but is missing here +# reaches execute()'s logs in cleartext. certipy uses single-dash long flags. +_CERTIPY_SECRET_FLAGS = frozenset({"-p", "-password", "-hashes", "-aes", "-pfx-password"}) + + +def _secrets_from_args(args: list[str]) -> list[str]: + """Recover credential values that follow a secret flag in an opaque ``args`` + list, so ``execute`` can mask them in logs.""" + return [value for flag, value in zip(args, args[1:], strict=False) if flag in _CERTIPY_SECRET_FLAGS] + + # 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. @@ -146,7 +157,11 @@ async def certipy( cmd.extend(["-dc-ip", dc_ip]) cmd.extend(args) - logger.info(f"Running '{self.certipy_cmd} {action}' with args: {' '.join(args)}") + arg_secrets = _secrets_from_args(args) + logged_args = " ".join(args) + for secret in arg_secrets: + logged_args = logged_args.replace(secret, "***") + logger.info(f"Running '{self.certipy_cmd} {action}' with args: {logged_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, @@ -159,7 +174,12 @@ async def certipy( env=None, ) - return await execute(cmd, timeout=self.timeout, input=input or _CONFIRM_INPUT) + return await execute( + cmd, + timeout=self.timeout, + input=input or _CONFIRM_INPUT, + secrets=[s for s in (password, nt_hash) if s] + arg_secrets, + ) # Specialized methods @@ -339,7 +359,12 @@ async def certipy_account(self, args: list[str], input: str | None = None) -> st value) auto-confirms certipy's interactive prompts with "y"; pass an explicit answer such as "n" to respond differently. """ - return await execute([self.certipy_cmd, "account", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT) + return await execute( + [self.certipy_cmd, "account", *args], + timeout=self.timeout, + input=input or _CONFIRM_INPUT, + secrets=_secrets_from_args(args), + ) @tool_method(catch=True, variants=["generic", "all"]) async def certipy_auth(self, args: list[str], input: str | None = None) -> str: @@ -390,7 +415,12 @@ async def certipy_auth(self, args: list[str], input: str | None = None) -> str: value) auto-confirms certipy's interactive prompts with "y"; pass an explicit answer such as "n" to respond differently. """ - return await execute([self.certipy_cmd, "auth", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT) + return await execute( + [self.certipy_cmd, "auth", *args], + timeout=self.timeout, + input=input or _CONFIRM_INPUT, + secrets=_secrets_from_args(args), + ) @tool_method(catch=True, variants=["generic", "all"]) async def certipy_ca(self, args: list[str], input: str | None = None) -> str: @@ -475,7 +505,12 @@ async def certipy_ca(self, args: list[str], input: str | None = None) -> str: value) auto-confirms certipy's interactive prompts with "y"; pass an explicit answer such as "n" to respond differently. """ - return await execute([self.certipy_cmd, "ca", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT) + return await execute( + [self.certipy_cmd, "ca", *args], + timeout=self.timeout, + input=input or _CONFIRM_INPUT, + secrets=_secrets_from_args(args), + ) @tool_method(catch=True, variants=["generic", "all"]) async def certipy_cert(self, args: list[str], input: str | None = None) -> str: @@ -509,7 +544,12 @@ async def certipy_cert(self, args: list[str], input: str | None = None) -> str: value) auto-confirms certipy's interactive prompts with "y"; pass an explicit answer such as "n" to respond differently. """ - return await execute([self.certipy_cmd, "cert", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT) + return await execute( + [self.certipy_cmd, "cert", *args], + timeout=self.timeout, + input=input or _CONFIRM_INPUT, + secrets=_secrets_from_args(args), + ) @tool_method(catch=True, variants=["generic", "all"]) async def certipy_find( @@ -680,7 +720,12 @@ async def certipy_req(self, args: list[str], input: str | None = None) -> str: value) auto-confirms certipy's interactive prompts with "y"; pass an explicit answer such as "n" to respond differently. """ - return await execute([self.certipy_cmd, "req", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT) + return await execute( + [self.certipy_cmd, "req", *args], + timeout=self.timeout, + input=input or _CONFIRM_INPUT, + secrets=_secrets_from_args(args), + ) @tool_method(catch=True, variants=["generic", "all"]) async def certipy_template(self, args: list[str], input: str | None = None) -> str: @@ -753,4 +798,9 @@ async def certipy_template(self, args: list[str], input: str | None = None) -> s value) auto-confirms certipy's interactive prompts with "y"; pass an explicit answer such as "n" to respond differently. """ - return await execute([self.certipy_cmd, "template", *args], timeout=self.timeout, input=input or _CONFIRM_INPUT) + return await execute( + [self.certipy_cmd, "template", *args], + timeout=self.timeout, + input=input or _CONFIRM_INPUT, + secrets=_secrets_from_args(args), + ) diff --git a/capabilities/network-ops/tools/coercion.py b/capabilities/network-ops/tools/coercion.py index b779fad..3d1a185 100644 --- a/capabilities/network-ops/tools/coercion.py +++ b/capabilities/network-ops/tools/coercion.py @@ -35,8 +35,7 @@ def _resolve_script(base_path: Path, script_name: str, repo_url: str) -> Path: if script.is_file(): return script raise FileNotFoundError( - f"Coercion script '{script_name}' not found at '{base_path}'. " - f"Install via: git clone {repo_url} {base_path}" + f"Coercion script '{script_name}' not found at '{base_path}'. " f"Install via: git clone {repo_url} {base_path}" ) @@ -180,7 +179,8 @@ async def coerce_petitpotam( args.extend([listener, target]) script = _resolve_script( - self.petitpotam_path, "PetitPotam.py", + self.petitpotam_path, + "PetitPotam.py", "https://github.com/topotam/PetitPotam", ) return await execute( @@ -188,6 +188,7 @@ async def coerce_petitpotam( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -292,7 +293,8 @@ async def coerce_dfscoerce( args.extend([listener, target]) script = _resolve_script( - self.dfscoerce_path, "dfscoerce.py", + self.dfscoerce_path, + "dfscoerce.py", "https://github.com/Wh04m1001/DFSCoerce", ) return await execute( @@ -300,6 +302,7 @@ async def coerce_dfscoerce( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -393,7 +396,8 @@ async def coerce_shadowcoerce( args.extend([listener, target]) script = _resolve_script( - self.shadowcoerce_path, "shadowcoerce.py", + self.shadowcoerce_path, + "shadowcoerce.py", "https://github.com/ShutdownRepo/ShadowCoerce", ) return await execute( @@ -401,4 +405,5 @@ async def coerce_shadowcoerce( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) diff --git a/capabilities/network-ops/tools/impacket.py b/capabilities/network-ops/tools/impacket.py index db33899..1a08054 100644 --- a/capabilities/network-ops/tools/impacket.py +++ b/capabilities/network-ops/tools/impacket.py @@ -754,6 +754,7 @@ async def impacket_rbcd( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -879,6 +880,7 @@ async def impacket_get_gpp_password( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -984,6 +986,7 @@ async def impacket_find_delegation( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -1094,6 +1097,7 @@ async def impacket_get_laps_password( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -1284,6 +1288,7 @@ async def impacket_ticketer( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (nthash, aes_key, request_password, request_hashes) if s], ) @tool_method(catch=True) @@ -1444,6 +1449,7 @@ async def impacket_get_st( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -1596,6 +1602,7 @@ async def impacket_get_user_spns( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -1765,6 +1772,7 @@ async def impacket_add_computer( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key, computer_pass) if s], ) @tool_method(catch=True) @@ -1883,6 +1891,7 @@ async def impacket_get_tgt( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True, variants=["all", "relay"]) @@ -2330,7 +2339,12 @@ async def impacket_ntlmrelay_attack( 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, + secrets=[s for s in (coerce_password, coerce_nt_hash) if s], + ) except (RuntimeError, TimeoutError) as exc: coerce_result = f"[coercion error] {exc}" logger.warning(f"Coercion failed: {exc}") @@ -2530,6 +2544,7 @@ async def impacket_owneredit( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -2750,6 +2765,7 @@ async def impacket_secretsdump( timeout=self.timeout + 120, # secretsdump can take longer input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -2870,6 +2886,7 @@ async def impacket_get_np_users( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -3030,6 +3047,7 @@ async def impacket_changepasswd( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key, newpass, newhashes, altpass, althash) if s], ) @tool_method(catch=True) @@ -3240,6 +3258,7 @@ async def impacket_dacledit( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -3351,6 +3370,7 @@ async def impacket_lookup_sid( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) # ------------------------------------------------------------------- @@ -3485,6 +3505,7 @@ async def impacket_wmiexec( timeout=timeout or self.timeout + 60, input=effective_input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -3613,6 +3634,7 @@ async def impacket_psexec( timeout=timeout or self.timeout + 60, input=effective_input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -3732,6 +3754,7 @@ async def impacket_smbexec( timeout=timeout or self.timeout + 60, input=stdin_input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -3830,6 +3853,7 @@ async def impacket_atexec( timeout=timeout or self.timeout + 60, input=input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) @tool_method(catch=True) @@ -3960,4 +3984,5 @@ async def impacket_dcomexec( timeout=timeout or self.timeout + 60, input=effective_input, env=env, + secrets=[s for s in (password, nt_hash, aes_key) if s], ) diff --git a/capabilities/network-ops/tools/krbrelayx.py b/capabilities/network-ops/tools/krbrelayx.py index a2157ae..ea248e2 100644 --- a/capabilities/network-ops/tools/krbrelayx.py +++ b/capabilities/network-ops/tools/krbrelayx.py @@ -213,6 +213,7 @@ async def krbrelayx_add_spn( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -382,6 +383,7 @@ async def krbrelayx_dns_tool( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) @tool_method(catch=True) @@ -623,4 +625,5 @@ async def krbrelayx_printer_bug( timeout=self.timeout, input=input, env=env, + secrets=[s for s in (password, nt_hash) if s], ) diff --git a/capabilities/network-ops/tools/netexec.py b/capabilities/network-ops/tools/netexec.py index adb79e8..1a044c3 100644 --- a/capabilities/network-ops/tools/netexec.py +++ b/capabilities/network-ops/tools/netexec.py @@ -6,6 +6,10 @@ from dreadnode.tools.execute import execute from loguru import logger +# Credential flags an agent may place in the passthrough ``args`` list; their +# value is recovered so execute() and the log line below mask it. +_NETEXEC_SECRET_FLAGS = frozenset({"-p", "--password", "-H", "--hash", "-aesKey", "--aesKey"}) + def _emit_auth_flag(value: str | list[str] | None) -> bool: """Whether an auth value should produce its command-line flag. @@ -83,9 +87,7 @@ async def netexec( cmd.extend(["-H", *(nt_hash if isinstance(nt_hash, list) else [nt_hash])]) if aes_key and ccache: - raise ValueError( - "Pass only one of aes_key or ccache for Kerberos authentication, not both." - ) + raise ValueError("Pass only one of aes_key or ccache for Kerberos authentication, not both.") env: dict[str, str] | None = None if kerberos or aes_key or ccache: @@ -98,8 +100,20 @@ async def netexec( cmd.extend(args) - logger.info(f"Running 'netexec {protocol}' with args: {' '.join(args)}") - return await execute(cmd, timeout=self.timeout, env=env) + secrets = [ + value + for candidate in (password, nt_hash, aes_key) + if candidate + for value in (candidate if isinstance(candidate, list) else [candidate]) + ] + arg_secrets = [value for flag, value in zip(args, args[1:], strict=False) if flag in _NETEXEC_SECRET_FLAGS] + secrets += arg_secrets + + logged_args = " ".join(args) + for secret in arg_secrets: + logged_args = logged_args.replace(secret, "***") + logger.info(f"Running 'netexec {protocol}' with args: {logged_args}") + return await execute(cmd, timeout=self.timeout, env=env, secrets=secrets) # Specialized methods diff --git a/capabilities/network-ops/tools/smbclient.py b/capabilities/network-ops/tools/smbclient.py index c1fc060..a707d07 100644 --- a/capabilities/network-ops/tools/smbclient.py +++ b/capabilities/network-ops/tools/smbclient.py @@ -13,10 +13,7 @@ def _require_smbclient() -> str: """Return the smbclient binary path, raising a clear error if missing.""" path = shutil.which("smbclient") if path is None: - raise FileNotFoundError( - "smbclient is not installed or not on PATH. " - "Install via: apt install smbclient" - ) + raise FileNotFoundError("smbclient is not installed or not on PATH. " "Install via: apt install smbclient") return path @@ -32,9 +29,7 @@ def _sanitize_smb_path(path: str) -> str: """ bad = _SMBCLIENT_UNSAFE_CHARS & set(path) if bad: - raise ValueError( - f"SMB path contains unsafe characters {bad!r}: {path!r}" - ) + raise ValueError(f"SMB path contains unsafe characters {bad!r}: {path!r}") return f'"{path}"' @@ -91,26 +86,27 @@ async def smb_list_files( except TimeoutError: proc.kill() await proc.wait() - raise TimeoutError( - f"smbclient timed out after {timeout}s listing {share_path}\\{path}" - ) + raise TimeoutError(f"smbclient timed out after {timeout}s listing {share_path}\\{path}") output = stdout.decode(errors="replace") errors = stderr.decode(errors="replace") + # This method runs smbclient directly (not via execute), so mask the + # password before it can reach the returned result or the raised error, + # both of which are recorded on the trace. + if password: + output = output.replace(password, "***") + errors = errors.replace(password, "***") + if proc.returncode != 0 and output.strip(): if errors.strip(): - logger.warning( - f"smbclient exited {proc.returncode} with partial output. Errors: {errors.strip()}" - ) + logger.warning(f"smbclient exited {proc.returncode} with partial output. Errors: {errors.strip()}") warning = "\n\n[smbclient exited non-zero — listing may be incomplete]" if errors.strip(): warning += f"\n{errors}" return f"{output}{warning}" if proc.returncode != 0: - raise RuntimeError( - f"Command failed ({proc.returncode}):\n{errors or output}" - ) + raise RuntimeError(f"Command failed ({proc.returncode}):\n{errors or output}") return output @@ -148,7 +144,8 @@ async def smb_download_file( f"{username}%{password}", "-c", smb_command, - ] + ], + secrets=[password], ) @tool_method(catch=True) @@ -197,7 +194,8 @@ async def smb_upload_file( f"{username}%{password}", "-c", smb_command, - ] + ], + secrets=[password], ) finally: if local_path is not None: