From 2ed9cc28e68e030ed8f83dbc5a946ffb35c67a4d Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 23 Jul 2026 13:26:09 -0400 Subject: [PATCH] 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 --- hate_crack/api.py | 55 +++++++++++++++++++++++++++++++------ tests/test_hashview.py | 62 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 15 deletions(-) diff --git a/hate_crack/api.py b/hate_crack/api.py index 8e463d2..b9e5bcc 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -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: diff --git a/tests/test_hashview.py b/tests/test_hashview.py index b7597bc..f266a1f 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -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."""