Skip to content

Commit 8e9327a

Browse files
authored
Merge pull request #176 from dreadnode/eng-8820-bloodyad-pass-the-hash
ENG-8820 fix(network-ops): pass bloodyAD hashes via -p for pass-the-hash
2 parents ac4420c + e2ea7a9 commit 8e9327a

2 files changed

Lines changed: 129 additions & 6 deletions

File tree

‎capabilities/network-ops/tests/test_tool_fixes.py‎

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from __future__ import annotations
66

77
import importlib.util
8+
import inspect
89
import sys
910
import types
1011
from pathlib import Path
@@ -105,6 +106,7 @@ def _load_module(name: str, filename: str):
105106
certipy_mod = _load_module("certipy_tools", "certipy.py")
106107
impacket_mod = _load_module("impacket_tools", "impacket.py")
107108
cracking_mod = _load_module("cracking_tools", "cracking.py")
109+
bloodyad_mod = _load_module("bloodyad_tools", "bloodyad.py")
108110

109111

110112
# ===================================================================
@@ -599,10 +601,14 @@ async def test_backend_error_suggests_john_with_known_mode(self, tmp_path):
599601
cracker = cracking_mod.Cracking()
600602

601603
async def fake_execute(cmd, **kwargs):
602-
raise RuntimeError("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR")
604+
raise RuntimeError(
605+
"Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR"
606+
)
603607

604608
with patch.object(cracking_mod, "execute", side_effect=fake_execute):
605-
with pytest.raises(RuntimeError, match=r"Use john_the_ripper.*hash_format='nt'"):
609+
with pytest.raises(
610+
RuntimeError, match=r"Use john_the_ripper.*hash_format='nt'"
611+
):
606612
await cracker.hashcat(
607613
hashcat_mode=1000,
608614
hash_file=str(hash_file),
@@ -618,7 +624,9 @@ async def test_backend_error_chains_original_exception(self, tmp_path):
618624
wordlist.write_text("password\n")
619625

620626
cracker = cracking_mod.Cracking()
621-
original = RuntimeError("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR")
627+
original = RuntimeError(
628+
"Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR"
629+
)
622630

623631
async def fake_execute(cmd, **kwargs):
624632
raise original
@@ -654,6 +662,117 @@ async def fake_execute(cmd, **kwargs):
654662
)
655663

656664

665+
# ===================================================================
666+
# BloodyAD: _build_connection_options (pass-the-hash auth)
667+
# ===================================================================
668+
669+
670+
class TestBuildConnectionOptions:
671+
"""A hash is supplied to bloodyAD through the password field (-p)."""
672+
673+
def _make_bloodyad(self):
674+
return bloodyad_mod.BloodyAD()
675+
676+
def _base_kwargs(self):
677+
return {"domain": "domain.local", "username": "alice", "host": "dc01"}
678+
679+
def test_bare_nt_hash_uses_leading_colon_password(self):
680+
bad = self._make_bloodyad()
681+
cmd = bad._build_connection_options(
682+
**self._base_kwargs(),
683+
hashes="aad3b435b51404eeaad3b435b51404ee",
684+
)
685+
assert "--hashes" not in cmd
686+
p_idx = cmd.index("-p")
687+
assert cmd[p_idx + 1] == ":aad3b435b51404eeaad3b435b51404ee"
688+
689+
def test_full_lm_nt_hash_passed_through_unchanged(self):
690+
bad = self._make_bloodyad()
691+
full = "aad3b435b51404eeaad3b435b51404ee:" "31d6cfe0d16ae931b73c59d7e0c089c0"
692+
cmd = bad._build_connection_options(**self._base_kwargs(), hashes=full)
693+
assert "--hashes" not in cmd
694+
p_idx = cmd.index("-p")
695+
assert cmd[p_idx + 1] == full
696+
697+
def test_password_takes_precedence_over_hashes(self):
698+
bad = self._make_bloodyad()
699+
cmd = bad._build_connection_options(
700+
**self._base_kwargs(),
701+
password="Summer2026!",
702+
hashes="deadbeef",
703+
)
704+
assert "--hashes" not in cmd
705+
p_idx = cmd.index("-p")
706+
assert cmd[p_idx + 1] == "Summer2026!"
707+
# The hash must not also leak into the argv.
708+
assert "deadbeef" not in cmd
709+
assert ":deadbeef" not in cmd
710+
711+
def test_no_hashes_flag_ever_emitted(self):
712+
bad = self._make_bloodyad()
713+
cmd = bad._build_connection_options(**self._base_kwargs(), hashes="deadbeef")
714+
assert "--hashes" not in cmd
715+
716+
717+
# Every public bloodyad_* tool method must forward its `hashes` argument into
718+
# the shared connection-options helper. Testing the helper alone would not
719+
# catch a method that dropped `hashes=hashes` on its own call, so this walks
720+
# the real tool surface.
721+
_BLOODYAD_TOOL_METHODS = sorted(
722+
name
723+
for name, member in inspect.getmembers(
724+
bloodyad_mod.BloodyAD, inspect.iscoroutinefunction
725+
)
726+
if name.startswith("bloodyad_")
727+
)
728+
729+
_CONNECTION_PARAMS = {"domain", "username", "host"}
730+
_NT_HASH = "aad3b435b51404eeaad3b435b51404ee"
731+
732+
733+
def _call_kwargs_for(method) -> dict:
734+
"""Minimal kwargs to invoke a tool method on the hash-auth path: every
735+
required parameter gets a placeholder, and `hashes` carries a bare NT hash
736+
while `password` is left unset so the hash branch is taken."""
737+
kwargs: dict = {}
738+
for name, param in inspect.signature(method).parameters.items():
739+
if name == "self":
740+
continue
741+
if name == "hashes":
742+
kwargs[name] = _NT_HASH
743+
elif name in _CONNECTION_PARAMS:
744+
kwargs[name] = f"test-{name}"
745+
elif param.default is inspect.Parameter.empty:
746+
kwargs[name] = "test-value"
747+
return kwargs
748+
749+
750+
class TestPublicMethodsForwardHashes:
751+
"""The ticket's failure was a tool authenticating without its hash. Guard
752+
every public tool method, not just the shared helper."""
753+
754+
def test_at_least_one_tool_method_discovered(self):
755+
# A rename/refactor that empties this list must fail loudly rather than
756+
# silently parametrizing over nothing and passing.
757+
assert len(_BLOODYAD_TOOL_METHODS) >= 20
758+
759+
@pytest.mark.parametrize("method_name", _BLOODYAD_TOOL_METHODS)
760+
@pytest.mark.asyncio
761+
async def test_hash_reaches_argv(self, method_name):
762+
bad = bloodyad_mod.BloodyAD()
763+
bad.timeout = 30
764+
method = getattr(bad, method_name)
765+
766+
captured = AsyncMock(return_value="ok")
767+
with patch.object(bloodyad_mod, "execute", captured):
768+
await method(**_call_kwargs_for(method))
769+
770+
cmd = captured.call_args.args[0]
771+
assert "--hashes" not in cmd
772+
p_idx = cmd.index("-p")
773+
assert cmd[p_idx + 1] == f":{_NT_HASH}"
774+
775+
657776
# ===================================================================
658777
# Cracking: results survive hashcat's non-zero / time-boxed exits
659778
# ===================================================================
@@ -789,7 +908,9 @@ async def test_no_backend_short_circuits_before_show(self, tmp_path):
789908
"""A missing GPU backend raises the john hint and never reaches --show."""
790909
hash_file, wordlist = self._write_inputs(tmp_path)
791910
fake_execute, calls = _split_execute(
792-
RuntimeError("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR"),
911+
RuntimeError(
912+
"Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR"
913+
),
793914
"svc_sql:Password1",
794915
)
795916
with patch.object(cracking_mod, "execute", side_effect=fake_execute):

‎capabilities/network-ops/tools/bloodyad.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def _build_connection_options(
6767
username: Username for authentication.
6868
host: Domain controller hostname or IP.
6969
password: Password for authentication.
70-
hashes: NTLM hashes for pass-the-hash.
70+
hashes: NTLM hash for pass-the-hash, as ``LMHASH:NTHASH`` or a bare NT hash.
7171
use_kerberos: Use Kerberos authentication.
7272
dc_ip: Optional DC IP to override DNS.
7373
@@ -79,7 +79,9 @@ def _build_connection_options(
7979
if password:
8080
cmd.extend(["-p", password])
8181
elif hashes:
82-
cmd.extend(["--hashes", hashes])
82+
# bloodyAD takes a hash through the password field: -p LMHASH:NTHASH,
83+
# or -p :NTHASH when only the NT half is known.
84+
cmd.extend(["-p", hashes if ":" in hashes else f":{hashes}"])
8385

8486
if use_kerberos:
8587
cmd.append("-k")

0 commit comments

Comments
 (0)