From ecedcbf7dcd602d879ccf79af00b7f425680a51e Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 23 Jul 2026 12:52:55 -0400 Subject: [PATCH] fix(hashview): validate cracked pairs client-side before upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upload_cracked_hashes sent every hash:plaintext pair to Hashview untouched. A single line whose plaintext doesn't match the declared hash mode (e.g. a stray MD5 hash mixed into an NTLM list) made Hashview reject the entire batch with an opaque "Plaintext for hash ... was found to be invalid" error. Add two client-side guards (default on, disable via validate=False): 1. Length filter — drop hashes whose hex width is wrong for the mode. 2. Plaintext verification — for reproducible fast modes (MD5, SHA1, MD4, NTLM, SHA2-256/512) recompute the digest from the plaintext ($HEX[...] decoded) and skip pairs that don't match. Skipped lines are reported with line number and reason instead of failing the whole upload; raise clearly if nothing valid remains. Ships a pure-Python MD4 since OpenSSL 3 dropped it from hashlib. Co-Authored-By: Claude Opus 4.8 --- hate_crack/api.py | 144 ++++++++++++++++++++++++++++++++++++++++- tests/test_hashview.py | 63 ++++++++++++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/hate_crack/api.py b/hate_crack/api.py index c2fa2e7..8e463d2 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -1024,6 +1024,121 @@ def weakpass_wordlist_menu(rank=-1): print(f"Error: {e}") +def _md4(data: bytes) -> str: + """Pure-Python MD4 (OpenSSL 3 dropped md4, so hashlib can't be relied on). + + Used to compute NTLM digests for client-side plaintext validation. + """ + import struct + + def lrot(x, n): + x &= 0xFFFFFFFF + return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF + + a, b, c, d = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476 + msg = bytearray(data) + ml = len(data) * 8 + msg.append(0x80) + while len(msg) % 64 != 56: + msg.append(0) + msg += struct.pack(" bytes: + """Decode a hashcat plaintext, expanding $HEX[...] to raw bytes.""" + if plaintext.startswith("$HEX[") and plaintext.endswith("]"): + inner = plaintext[5:-1] + try: + return bytes.fromhex(inner) + except ValueError: + return plaintext.encode("utf-8", "surrogateescape") + return plaintext.encode("utf-8", "surrogateescape") + + +def _digest_for_type(hash_type: str, raw: bytes) -> Optional[str]: + """Compute the digest of ``raw`` under ``hash_type``. + + Returns None for hash types we can't verify client-side (salted, + iterated, or otherwise not reproducible from plaintext alone). + """ + import hashlib + + ht = str(hash_type) + if ht == "0": + return hashlib.md5(raw).hexdigest() + if ht == "100": + return hashlib.sha1(raw).hexdigest() + if ht == "1400": + return hashlib.sha256(raw).hexdigest() + 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. + if ht == "1000": + try: + text = raw.decode("utf-8", "surrogateescape") + return _md4(text.encode("utf-16le", "surrogateescape")) + except Exception: + return None + return _md4(raw) + return None + + +def _validate_cracked_pair(hash_type, hash_value, plaintext): + """Return (ok, reason) for a single hash:plaintext pair. + + ``ok`` False means the pair should be skipped. ``reason`` is a short + human-readable explanation for the warning. Unverifiable types pass. + """ + ht = str(hash_type) + expected_len = _HASH_HEX_LEN.get(ht) + if expected_len is not None and len(hash_value) != expected_len: + return ( + False, + f"wrong length ({len(hash_value)} chars, expected {expected_len} for mode {ht})", + ) + digest = _digest_for_type(ht, _decode_plaintext(plaintext)) + if digest is not None and digest.lower() != hash_value.lower(): + return (False, f"plaintext does not match hash under mode {ht}") + return (True, "") + + # Hashview Integration - Real API implementation matching hate_crack.py class HashviewAPI: def _auth_headers(self): @@ -1567,10 +1682,11 @@ class HashviewAPI: "combined_file": combined_file, } - def upload_cracked_hashes(self, file_path, hash_type="1000"): + def upload_cracked_hashes(self, file_path, hash_type="1000", *, validate=True): valid_lines = [] + skipped = [] with open(file_path, "r", encoding="utf-8", errors="ignore") as f: - for line in f: + for lineno, line in enumerate(f, 1): line = line.strip() if "31d6cfe0d16ae931b73c59d7e0c089c0" in line: continue @@ -1581,7 +1697,31 @@ class HashviewAPI: break hash_value = parts[0].strip() plaintext = parts[1].strip() + if validate: + ok, reason = _validate_cracked_pair( + hash_type, hash_value, plaintext + ) + if not ok: + skipped.append((lineno, hash_value, reason)) + continue valid_lines.append(f"{hash_value}:{plaintext}") + + if skipped: + print( + f"⚠ Skipped {len(skipped)} line(s) that do not match hash mode " + f"{hash_type} (would be rejected by Hashview):" + ) + for lineno, hash_value, reason in skipped[:10]: + print(f" line {lineno}: {hash_value} — {reason}") + if len(skipped) > 10: + print(f" ... and {len(skipped) - 10} more") + + if not valid_lines: + raise Exception( + f"No valid hashes to upload for hash mode {hash_type} " + f"({len(skipped)} line(s) skipped by validation)." + ) + converted_content = "\n".join(valid_lines) url = f"{self.base_url}/v1/hashes/import/{hash_type}" headers = {"Content-Type": "text/plain"} diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 0830b32..b7597bc 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -353,6 +353,69 @@ class TestHashviewAPI: api.upload_cracked_hashes(str(cracked_file), hash_type="1000") assert "Invalid API response" in str(excinfo.value) + def test_upload_skips_wrong_type_line(self, api, tmp_path, capsys): + """An MD5 line mixed into an NTLM upload is filtered client-side.""" + cracked_file = tmp_path / "cracked.txt" + cracked_file.write_text( + # MD5("password") — invalid as NTLM, must be dropped + "5f4dcc3b5aa765d61d8327deb882cf99:password\n" + # genuine NTLM("password") — must be kept + "8846f7eaee8fb117ad06bdd830b7586c:password\n" + ) + mock_response = Mock() + mock_response.json.return_value = {"imported": 1} + mock_response.raise_for_status = Mock() + api.session.post.return_value = mock_response + + result = api.upload_cracked_hashes(str(cracked_file), hash_type="1000") + assert result["imported"] == 1 + # Only the valid NTLM line should have been sent + 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 + 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.""" + cracked_file = tmp_path / "cracked.txt" + # NTLM of "%032023RC$ " emitted by hashcat as $HEX[...] + cracked_file.write_text( + "c153ace1d5b148820dab48a8aa5aa02e:$HEX[2530333230323352432420]\n" + ) + mock_response = Mock() + mock_response.json.return_value = {"imported": 1} + mock_response.raise_for_status = Mock() + api.session.post.return_value = mock_response + + result = api.upload_cracked_hashes(str(cracked_file), hash_type="1000") + assert result["imported"] == 1 + + def test_upload_all_invalid_raises(self, api, tmp_path): + """If validation drops every line, we raise instead of posting empty.""" + cracked_file = tmp_path / "cracked.txt" + cracked_file.write_text("5f4dcc3b5aa765d61d8327deb882cf99:password\n") + api.session.post.side_effect = AssertionError("should not POST") + with pytest.raises(Exception) as excinfo: + api.upload_cracked_hashes(str(cracked_file), hash_type="1000") + assert "No valid hashes" in str(excinfo.value) + + def test_upload_validation_can_be_disabled(self, api, tmp_path): + """validate=False restores the old permissive behaviour.""" + cracked_file = tmp_path / "cracked.txt" + cracked_file.write_text("5f4dcc3b5aa765d61d8327deb882cf99:password\n") + mock_response = Mock() + mock_response.json.return_value = {"imported": 1} + mock_response.raise_for_status = Mock() + api.session.post.return_value = mock_response + + result = api.upload_cracked_hashes( + str(cracked_file), hash_type="1000", validate=False + ) + assert result["imported"] == 1 + def test_create_customer_success(self, api): """Test creating a customer (real API if possible).""" hashview_url, hashview_api_key = self._get_hashview_config()