diff --git a/capabilities/network-ops/tests/test_tool_fixes.py b/capabilities/network-ops/tests/test_tool_fixes.py index f280e4d..24fe376 100644 --- a/capabilities/network-ops/tests/test_tool_fixes.py +++ b/capabilities/network-ops/tests/test_tool_fixes.py @@ -104,6 +104,7 @@ def _load_module(name: str, filename: str): 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") # =================================================================== @@ -577,3 +578,83 @@ async def mock_wait(): await impacket_mod._kill_relay(proc) mock_getpgid.assert_called_once_with(99999) mock_killpg.assert_called() + + +# =================================================================== +# Cracking: hashcat no-backend detection +# =================================================================== + + +class TestHashcatNoBackendDetection: + """hashcat should raise an actionable error when no GPU backend is available.""" + + @pytest.mark.asyncio + async def test_backend_error_suggests_john_with_known_mode(self, tmp_path): + """CL_PLATFORM_NOT_FOUND_KHR should suggest john_the_ripper with mapped format.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text("aad3b435b51404eeaad3b435b51404ee") + wordlist = tmp_path / "wordlist.txt" + wordlist.write_text("password\n") + + cracker = cracking_mod.Cracking() + + async def fake_execute(cmd, **kwargs): + 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'" + ): + await cracker.hashcat( + hashcat_mode=1000, + hash_file=str(hash_file), + wordlist_path=str(wordlist), + ) + + @pytest.mark.asyncio + async def test_backend_error_chains_original_exception(self, tmp_path): + """The original hashcat error should be preserved as __cause__.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text("aad3b435b51404eeaad3b435b51404ee") + wordlist = tmp_path / "wordlist.txt" + wordlist.write_text("password\n") + + cracker = cracking_mod.Cracking() + original = RuntimeError( + "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" + ) + + async def fake_execute(cmd, **kwargs): + raise original + + with patch.object(cracking_mod, "execute", side_effect=fake_execute): + with pytest.raises(RuntimeError) as exc_info: + await cracker.hashcat( + hashcat_mode=1000, + hash_file=str(hash_file), + wordlist_path=str(wordlist), + ) + assert exc_info.value.__cause__ is original + + @pytest.mark.asyncio + async def test_non_backend_error_propagates_unchanged(self, tmp_path): + """Errors unrelated to missing backends should propagate as-is.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text("badhash") + wordlist = tmp_path / "wordlist.txt" + wordlist.write_text("password\n") + + cracker = cracking_mod.Cracking() + + async def fake_execute(cmd, **kwargs): + raise RuntimeError("Command failed (1): Hash format mismatch") + + with patch.object(cracking_mod, "execute", side_effect=fake_execute): + with pytest.raises(RuntimeError, match=r"Hash format mismatch"): + await cracker.hashcat( + hashcat_mode=1000, + hash_file=str(hash_file), + wordlist_path=str(wordlist), + ) diff --git a/capabilities/network-ops/tools/cracking.py b/capabilities/network-ops/tools/cracking.py index 9d8d406..3132fef 100644 --- a/capabilities/network-ops/tools/cracking.py +++ b/capabilities/network-ops/tools/cracking.py @@ -9,6 +9,15 @@ g_hashcat_lock = asyncio.Lock() +HASHCAT_TO_JOHN: dict[int, str] = { + 1000: "nt", + 2100: "mscash2", + 13100: "krb5tgs", + 18200: "krb5asrep", +} + +_NO_BACKEND_MARKERS = ("CL_PLATFORM_NOT_FOUND_KHR", "No devices found/left") + class Cracking(Toolset): """ @@ -56,33 +65,53 @@ async def hashcat( raise FileNotFoundError(f"Hash file {hash_file_path} does not exist.") if not os.path.exists(wordlist_path): - raise FileNotFoundError(f"Wordlist file {wordlist_path} does not exist.") + raise FileNotFoundError( + f"Wordlist file {wordlist_path} does not exist." + ) logger.info( f"Cracking {hash_file_path} with mode {hashcat_mode} using wordlist {wordlist_path}" ) # Execute the cracking command - await execute( - [ - "hashcat", - "-m", - str(hashcat_mode), - "-a", - "0", - hash_file_path, - wordlist_path, - "--runtime", - str(max_time_minutes * 60), - "--force", - ], - timeout=(max_time_minutes * 60) + 30, - ) + try: + await execute( + [ + "hashcat", + "-m", + str(hashcat_mode), + "-a", + "0", + hash_file_path, + wordlist_path, + "--runtime", + str(max_time_minutes * 60), + "--force", + ], + timeout=(max_time_minutes * 60) + 30, + ) + except Exception as e: + msg = str(e) + if any(marker in msg for marker in _NO_BACKEND_MARKERS): + john_fmt = HASHCAT_TO_JOHN.get(hashcat_mode) + if john_fmt: + raise RuntimeError( + f"hashcat failed: no OpenCL/CUDA/HIP backend available. " + f"Use john_the_ripper instead with hash_format='{john_fmt}'." + ) from e + raise RuntimeError( + f"hashcat failed: no OpenCL/CUDA/HIP backend available. " + f"Use john_the_ripper instead (hashcat mode {hashcat_mode} " + f"has no automatic john format mapping — consult john documentation)." + ) from e + raise # Execute the --show command to get the cracked results - return await execute(["hashcat", "-m", str(hashcat_mode), hash_file_path, "--show"]) + return await execute( + ["hashcat", "-m", str(hashcat_mode), hash_file_path, "--show"] + ) - @tool_method(catch=True, variants=["john", "all"]) + @tool_method(catch=True, variants=["hashcat", "john", "all"]) async def john_the_ripper( self, hash_format: str, @@ -138,4 +167,6 @@ async def john_the_ripper( ) # Execute the --show command to get the cracked results - return await execute(["john", "--show", f"--format={hash_format}", hash_file_path]) + return await execute( + ["john", "--show", f"--format={hash_format}", hash_file_path] + )