From 3a3f7aedae4330f172e853d1801847c4fe5ed32a Mon Sep 17 00:00:00 2001 From: Thomas Q Brady Date: Wed, 14 Oct 2026 17:10:17 -0400 Subject: [PATCH] fix(network-ops): pass bloodyAD hashes via -p for pass-the-hash (ENG-8820) bloodyAD has no --hashes flag; the shared connection-options builder now supplies a hash through the password field (-p LMHASH:NTHASH, or -p :NTHASH for an NT-only hash), restoring pass-the-hash across all bloodyAD tool methods. Password and Kerberos auth are unchanged. Adds unit tests for the built argv, including one per public bloodyad_* method so a method that forgets to forward its hash fails loudly. --- .../network-ops/tests/test_tool_fixes.py | 113 ++++++++++++++++++ capabilities/network-ops/tools/bloodyad.py | 6 +- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/capabilities/network-ops/tests/test_tool_fixes.py b/capabilities/network-ops/tests/test_tool_fixes.py index 24fe3765..59de02d3 100644 --- a/capabilities/network-ops/tests/test_tool_fixes.py +++ b/capabilities/network-ops/tests/test_tool_fixes.py @@ -5,6 +5,7 @@ from __future__ import annotations import importlib.util +import inspect import sys import types from pathlib import Path @@ -105,6 +106,7 @@ def _load_module(name: str, filename: str): 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") # =================================================================== @@ -658,3 +660,114 @@ async def fake_execute(cmd, **kwargs): hash_file=str(hash_file), wordlist_path=str(wordlist), ) + + +# =================================================================== +# BloodyAD: _build_connection_options (pass-the-hash auth) +# =================================================================== + + +class TestBuildConnectionOptions: + """A hash is supplied to bloodyAD through the password field (-p).""" + + def _make_bloodyad(self): + return bloodyad_mod.BloodyAD() + + def _base_kwargs(self): + return {"domain": "domain.local", "username": "alice", "host": "dc01"} + + def test_bare_nt_hash_uses_leading_colon_password(self): + bad = self._make_bloodyad() + cmd = bad._build_connection_options( + **self._base_kwargs(), + hashes="aad3b435b51404eeaad3b435b51404ee", + ) + assert "--hashes" not in cmd + p_idx = cmd.index("-p") + assert cmd[p_idx + 1] == ":aad3b435b51404eeaad3b435b51404ee" + + def test_full_lm_nt_hash_passed_through_unchanged(self): + bad = self._make_bloodyad() + full = "aad3b435b51404eeaad3b435b51404ee:" "31d6cfe0d16ae931b73c59d7e0c089c0" + cmd = bad._build_connection_options(**self._base_kwargs(), hashes=full) + assert "--hashes" not in cmd + p_idx = cmd.index("-p") + assert cmd[p_idx + 1] == full + + def test_password_takes_precedence_over_hashes(self): + bad = self._make_bloodyad() + cmd = bad._build_connection_options( + **self._base_kwargs(), + password="Summer2026!", + hashes="deadbeef", + ) + assert "--hashes" not in cmd + p_idx = cmd.index("-p") + assert cmd[p_idx + 1] == "Summer2026!" + # The hash must not also leak into the argv. + assert "deadbeef" not in cmd + assert ":deadbeef" not in cmd + + def test_no_hashes_flag_ever_emitted(self): + bad = self._make_bloodyad() + cmd = bad._build_connection_options(**self._base_kwargs(), hashes="deadbeef") + assert "--hashes" not in cmd + + +# Every public bloodyad_* tool method must forward its `hashes` argument into +# the shared connection-options helper. Testing the helper alone would not +# catch a method that dropped `hashes=hashes` on its own call, so this walks +# the real tool surface. +_BLOODYAD_TOOL_METHODS = sorted( + name + for name, member in inspect.getmembers( + bloodyad_mod.BloodyAD, inspect.iscoroutinefunction + ) + if name.startswith("bloodyad_") +) + +_CONNECTION_PARAMS = {"domain", "username", "host"} +_NT_HASH = "aad3b435b51404eeaad3b435b51404ee" + + +def _call_kwargs_for(method) -> dict: + """Minimal kwargs to invoke a tool method on the hash-auth path: every + required parameter gets a placeholder, and `hashes` carries a bare NT hash + while `password` is left unset so the hash branch is taken.""" + kwargs: dict = {} + for name, param in inspect.signature(method).parameters.items(): + if name == "self": + continue + if name == "hashes": + kwargs[name] = _NT_HASH + elif name in _CONNECTION_PARAMS: + kwargs[name] = f"test-{name}" + elif param.default is inspect.Parameter.empty: + kwargs[name] = "test-value" + return kwargs + + +class TestPublicMethodsForwardHashes: + """The ticket's failure was a tool authenticating without its hash. Guard + every public tool method, not just the shared helper.""" + + def test_at_least_one_tool_method_discovered(self): + # A rename/refactor that empties this list must fail loudly rather than + # silently parametrizing over nothing and passing. + assert len(_BLOODYAD_TOOL_METHODS) >= 20 + + @pytest.mark.parametrize("method_name", _BLOODYAD_TOOL_METHODS) + @pytest.mark.asyncio + async def test_hash_reaches_argv(self, method_name): + bad = bloodyad_mod.BloodyAD() + bad.timeout = 30 + method = getattr(bad, method_name) + + captured = AsyncMock(return_value="ok") + with patch.object(bloodyad_mod, "execute", captured): + await method(**_call_kwargs_for(method)) + + cmd = captured.call_args.args[0] + assert "--hashes" not in cmd + p_idx = cmd.index("-p") + assert cmd[p_idx + 1] == f":{_NT_HASH}" diff --git a/capabilities/network-ops/tools/bloodyad.py b/capabilities/network-ops/tools/bloodyad.py index c72a4ae9..a5882b1f 100644 --- a/capabilities/network-ops/tools/bloodyad.py +++ b/capabilities/network-ops/tools/bloodyad.py @@ -67,7 +67,7 @@ def _build_connection_options( username: Username for authentication. host: Domain controller hostname or IP. password: Password for authentication. - hashes: NTLM hashes for pass-the-hash. + hashes: NTLM hash for pass-the-hash, as ``LMHASH:NTHASH`` or a bare NT hash. use_kerberos: Use Kerberos authentication. dc_ip: Optional DC IP to override DNS. @@ -79,7 +79,9 @@ def _build_connection_options( if password: cmd.extend(["-p", password]) elif hashes: - cmd.extend(["--hashes", hashes]) + # bloodyAD takes a hash through the password field: -p LMHASH:NTHASH, + # or -p :NTHASH when only the NT half is known. + cmd.extend(["-p", hashes if ":" in hashes else f":{hashes}"]) if use_kerberos: cmd.append("-k")