Merge branch 'fix/upload-hex-decode' (2.10.11)

This commit is contained in:
Justin Bollinger
2026-07-23 13:59:22 -04:00
3 changed files with 127 additions and 15 deletions
+25
View File
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Dates are omitted for releases predating this file; see the git tags for exact timing.
## [2.10.11] - 2026-07-23
### Fixed
- **Hashview cracked-hash uploads no longer choke on hashcat `$HEX[...]` plaintexts.**
hashcat emits `$HEX[...]` for recovered passwords containing leading/trailing
whitespace or non-UTF-8 bytes. `upload_cracked_hashes` forwarded those verbatim, so a
Hashview that verifies the plaintext against the hash rejected the entire batch with
`Plaintext for hash ... was found to be invalid.` The uploader now decodes `$HEX[...]`
to the exact bytes the server must re-hash — latin-1→UTF-8 for the UTF-16LE modes
(NTLM 1000, MSSQL 1731), raw bytes for the raw-byte modes (0/100/300/900/1400/1700) —
and keeps the `$HEX` wrapper verbatim when inlining would be unsafe (embedded CR/LF) so
a `$HEX`-aware server can still handle it. Verified end-to-end against an unpatched
Hashview.
### Added
- **Client-side validation of cracked hash:plaintext pairs before Hashview upload.**
`upload_cracked_hashes` now filters each pair against the declared hashcat mode: a
length check for wrong-width hashes, plus a plaintext recompute for the reproducible
fast modes (MD5, SHA1, MD4, NTLM, SHA2-256/512). Mismatched lines (e.g. a stray MD5
hash mixed into an NTLM list) are skipped with a per-line warning instead of failing
the whole upload server-side, and it raises clearly if nothing valid remains. Bundles a
pure-Python MD4 since OpenSSL 3 dropped it from `hashlib`. Opt out with `validate=False`.
## [2.10.10] - 2026-07-21
### Security
+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."""