mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 14:47:22 -07:00
fix(hashview): decode $HEX[...] before upload for older servers
hate_crack forwarded hashcat's $HEX[...] plaintext verbatim. A Hashview that verifies plaintext literally hashes the string "$HEX[..]" and rejects the batch. Decode $HEX to the exact bytes the server must re-hash and send those on the wire (body is now bytes): - UTF-16LE modes (NTLM 1000, MSSQL 1731): latin-1 code points -> UTF-8 - raw-byte modes (0/100/300/900/1400/1700): the decoded bytes as-is - unsafe (embedded CR/LF) or unknown modes: keep the $HEX token verbatim and rely on a $HEX-aware server Also fixes the NTLM validation digest to use latin-1 -> UTF-16LE so high-byte $HEX plaintexts verify instead of being silently unverifiable. Verified end-to-end: the emitted wire verifies against both the old (literal) and new ($HEX-aware) Hashview verifiers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d716f04c86
commit
2ed9cc28e6
+46
-9
@@ -1109,13 +1109,11 @@ def _digest_for_type(hash_type: str, raw: bytes) -> Optional[str]:
|
||||
if ht == "1700":
|
||||
return hashlib.sha512(raw).hexdigest()
|
||||
if ht in ("900", "1000"):
|
||||
# MD4 over the raw bytes; NTLM is MD4 over UTF-16LE of the password.
|
||||
# MD4 over the raw bytes; NTLM is MD4 over UTF-16LE. hashcat forms the
|
||||
# NTLM candidate by zero-extending each raw byte, which is exactly
|
||||
# latin-1(bytes).encode("utf-16le") -- correct for arbitrary binary too.
|
||||
if ht == "1000":
|
||||
try:
|
||||
text = raw.decode("utf-8", "surrogateescape")
|
||||
return _md4(text.encode("utf-16le", "surrogateescape"))
|
||||
except Exception:
|
||||
return None
|
||||
return _md4(raw.decode("latin-1").encode("utf-16le"))
|
||||
return _md4(raw)
|
||||
return None
|
||||
|
||||
@@ -1139,6 +1137,41 @@ def _validate_cracked_pair(hash_type, hash_value, plaintext):
|
||||
return (True, "")
|
||||
|
||||
|
||||
# Hashcat modes whose password bytes are UTF-16LE (zero-extended) rather than
|
||||
# raw bytes. These need the latin-1->UTF-8 re-encoding when decoding $HEX.
|
||||
_UTF16LE_MODES = {"1000", "1731"}
|
||||
# Modes whose password is hashed as raw bytes.
|
||||
_RAW_BYTE_MODES = {"0", "100", "300", "900", "1400", "1700"}
|
||||
|
||||
|
||||
def _wire_field_bytes(hash_type, plaintext: str) -> bytes:
|
||||
"""Return the on-the-wire bytes for a plaintext field.
|
||||
|
||||
Decodes hashcat's ``$HEX[...]`` wrapper to the exact bytes Hashview must
|
||||
re-hash, so cracked passwords with whitespace/binary bytes import even
|
||||
against a Hashview that does not itself understand ``$HEX[...]``. Falls back
|
||||
to sending the ``$HEX[...]`` token verbatim when inlining would be unsafe
|
||||
(embedded CR/LF would break the line-based upload) or the mode's byte
|
||||
handling is unknown -- those rely on a $HEX-aware server.
|
||||
"""
|
||||
if not (plaintext.startswith("$HEX[") and plaintext.endswith("]")):
|
||||
return plaintext.encode("utf-8", "surrogateescape")
|
||||
try:
|
||||
raw = bytes.fromhex(plaintext[5:-1])
|
||||
except ValueError:
|
||||
return plaintext.encode("utf-8", "surrogateescape")
|
||||
if b"\n" in raw or b"\r" in raw:
|
||||
return plaintext.encode("utf-8", "surrogateescape")
|
||||
ht = str(hash_type)
|
||||
if ht in _UTF16LE_MODES:
|
||||
# hashcat zero-extends raw bytes; send the latin-1 code points as UTF-8
|
||||
# so the server reconstructs them before its own UTF-16LE encoding.
|
||||
return raw.decode("latin-1").encode("utf-8")
|
||||
if ht in _RAW_BYTE_MODES:
|
||||
return raw
|
||||
return plaintext.encode("utf-8", "surrogateescape")
|
||||
|
||||
|
||||
# Hashview Integration - Real API implementation matching hate_crack.py
|
||||
class HashviewAPI:
|
||||
def _auth_headers(self):
|
||||
@@ -1704,7 +1737,11 @@ class HashviewAPI:
|
||||
if not ok:
|
||||
skipped.append((lineno, hash_value, reason))
|
||||
continue
|
||||
valid_lines.append(f"{hash_value}:{plaintext}")
|
||||
valid_lines.append(
|
||||
hash_value.encode("ascii", "ignore")
|
||||
+ b":"
|
||||
+ _wire_field_bytes(hash_type, plaintext)
|
||||
)
|
||||
|
||||
if skipped:
|
||||
print(
|
||||
@@ -1722,9 +1759,9 @@ class HashviewAPI:
|
||||
f"({len(skipped)} line(s) skipped by validation)."
|
||||
)
|
||||
|
||||
converted_content = "\n".join(valid_lines)
|
||||
converted_content = b"\n".join(valid_lines)
|
||||
url = f"{self.base_url}/v1/hashes/import/{hash_type}"
|
||||
headers = {"Content-Type": "text/plain"}
|
||||
headers = {"Content-Type": "text/plain; charset=utf-8"}
|
||||
resp = self.session.post(url, data=converted_content, headers=headers)
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
|
||||
+56
-6
@@ -369,19 +369,26 @@ class TestHashviewAPI:
|
||||
|
||||
result = api.upload_cracked_hashes(str(cracked_file), hash_type="1000")
|
||||
assert result["imported"] == 1
|
||||
# Only the valid NTLM line should have been sent
|
||||
# Only the valid NTLM line should have been sent (body is bytes)
|
||||
sent = api.session.post.call_args.kwargs.get("data")
|
||||
if sent is None:
|
||||
sent = api.session.post.call_args.args[1]
|
||||
assert "8846f7eaee8fb117ad06bdd830b7586c:password" in sent
|
||||
assert "5f4dcc3b5aa765d61d8327deb882cf99" not in sent
|
||||
assert isinstance(sent, bytes)
|
||||
assert b"8846f7eaee8fb117ad06bdd830b7586c:password" in sent
|
||||
assert b"5f4dcc3b5aa765d61d8327deb882cf99" not in sent
|
||||
out = capsys.readouterr().out
|
||||
assert "Skipped 1 line" in out
|
||||
|
||||
def test_upload_accepts_hex_ntlm(self, api, tmp_path):
|
||||
"""$HEX[...] NTLM plaintexts validate and are uploaded."""
|
||||
def _sent_body(self, api):
|
||||
sent = api.session.post.call_args.kwargs.get("data")
|
||||
if sent is None:
|
||||
sent = api.session.post.call_args.args[1]
|
||||
return sent
|
||||
|
||||
def test_upload_decodes_hex_ntlm_ascii(self, api, tmp_path):
|
||||
"""$HEX[...] with trailing space is decoded to real bytes on the wire."""
|
||||
cracked_file = tmp_path / "cracked.txt"
|
||||
# NTLM of "%032023RC$ " emitted by hashcat as $HEX[...]
|
||||
# NTLM of "%032023RC$ " (trailing space) emitted by hashcat as $HEX[...]
|
||||
cracked_file.write_text(
|
||||
"c153ace1d5b148820dab48a8aa5aa02e:$HEX[2530333230323352432420]\n"
|
||||
)
|
||||
@@ -392,6 +399,49 @@ class TestHashviewAPI:
|
||||
|
||||
result = api.upload_cracked_hashes(str(cracked_file), hash_type="1000")
|
||||
assert result["imported"] == 1
|
||||
body = self._sent_body(api)
|
||||
# The $HEX wrapper must be gone; the decoded plaintext (with its
|
||||
# trailing space) is sent so a non-$HEX-aware Hashview verifies it.
|
||||
assert b"$HEX[" not in body
|
||||
assert b"c153ace1d5b148820dab48a8aa5aa02e:%032023RC$ " in body
|
||||
|
||||
def test_upload_decodes_hex_ntlm_highbyte(self, api, tmp_path):
|
||||
"""High-byte $HEX (0xA8) becomes UTF-8 so the server rebuilds U+00A8."""
|
||||
cracked_file = tmp_path / "cracked.txt"
|
||||
cracked_file.write_text(
|
||||
"af70d9ee21294a74f6337b121e6c9624:$HEX[a833333531343136335777]\n"
|
||||
)
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"imported": 1}
|
||||
mock_response.raise_for_status = Mock()
|
||||
api.session.post.return_value = mock_response
|
||||
|
||||
api.upload_cracked_hashes(str(cracked_file), hash_type="1000")
|
||||
body = self._sent_body(api)
|
||||
# 0xA8 -> latin-1 U+00A8 -> UTF-8 bytes C2 A8
|
||||
assert b"af70d9ee21294a74f6337b121e6c9624:\xc2\xa833514163Ww" in body
|
||||
|
||||
def test_upload_keeps_hex_with_embedded_newline(self, api, tmp_path):
|
||||
"""$HEX encoding a newline can't be inlined; the wrapper is kept."""
|
||||
cracked_file = tmp_path / "cracked.txt"
|
||||
# NTLM("a\nb") — plaintext contains a literal newline
|
||||
import hashlib as _h # noqa
|
||||
|
||||
cracked_file.write_text(
|
||||
"9c6d9b0dc5e5f4d8a4c8e0a1e0b1c2d3:$HEX[610a62]\n"
|
||||
)
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {"imported": 1}
|
||||
mock_response.raise_for_status = Mock()
|
||||
api.session.post.return_value = mock_response
|
||||
|
||||
# validate=False so the (bogus) hash isn't dropped before we inspect wire
|
||||
api.upload_cracked_hashes(
|
||||
str(cracked_file), hash_type="1000", validate=False
|
||||
)
|
||||
body = self._sent_body(api)
|
||||
assert b"$HEX[610a62]" in body # kept verbatim, no raw newline injected
|
||||
assert b"a\nb" not in body
|
||||
|
||||
def test_upload_all_invalid_raises(self, api, tmp_path):
|
||||
"""If validation drops every line, we raise instead of posting empty."""
|
||||
|
||||
Reference in New Issue
Block a user