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:
Justin Bollinger
2026-07-23 13:26:09 -04:00
co-authored by Claude Opus 4.8
parent d716f04c86
commit 2ed9cc28e6
2 changed files with 102 additions and 15 deletions
+46 -9
View File
@@ -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
View File
@@ -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."""