Summary
import_hashfilehashes decides how to normalise an incoming hash by comparing hash_type against string literals. The web UI supplies hash_type as a string (form field), so those branches fire. The API route is declared <int:hash_type>, so it supplies an int and every one of those branches is skipped.
The most damaging consequence: NTLM ciphertexts uploaded through POST /v1/hashfiles/upload/... are stored verbatim instead of lowercased. Since hashcat emits hex hashes lowercased, the md5(ciphertext) lookup that ingests crack results never matches those rows — so an API-imported NTLM hash from an uppercase dump can be cracked by an agent over and over and never be marked cracked. Its plaintext therefore never reaches the (DYNAMIC) All Recovered Passwords wordlist, and "instacrack" on import reports 0 against a corpus that already has the answer.
This is the exact failure the code one branch below already warns about (hashview/utils/utils.py:721-723):
hashcat emits hex hashes (e.g. NTLM) lowercased, so store them lowercased too -- otherwise the md5(ciphertext) lookup on crack upload misses
Reproduction
Against the real endpoints (in-memory SQLite, tests/unit fixtures), uploading 8846F7EAEE8FB117AD06BDD830B7586C (NTLM for password) as file_format=5 (hash_only), hash_type=1000:
| Step |
Result |
POST /v1/hashfiles/upload/<cust>/5/1000/hf |
stored as 8846F7EAEE8FB117AD06BDD830B7586C — the same line imported through the UI path (hash_type='1000') is stored 8846f7ea…586c |
Agent POST /v1/uploadCrackFile/<jt> with 8846f7ea…586c:<hex plain> |
cracked=False, plaintext=None — no match |
Regenerate (DYNAMIC) All Recovered Passwords |
empty |
With a pre-existing cracked copy of that hash in the DB, the same upload returns "instacracked": 0 and creates a duplicate Hashes row, after which (DYNAMIC) All NTLM Hashes contains both the lowercase and the uppercase form of one hash.
Where
hashview/utils/utils.py, import_hashfilehashes (line 665). Every site below is dead on the API path:
| Line |
Comparison |
Effect when skipped (API / int) |
| 688 |
if hash_type in ('300', '1731', '1000') |
hash_only NTLM/LM/half-NTLM not lowercased |
| 690 |
elif hash_type == '2100' |
hash_only DCC2 not lowercased, $dcc2$ → $DCC2$ normalisation skipped |
| 697 |
if hash_type == '2100' |
hash_only DCC2 username never extracted (stays NULL) |
| 711 |
if hash_type == '300' or hash_type == '1731' |
user_hash LM/half-NTLM not lowercased |
| 714 |
elif hash_type == '2100' |
user_hash DCC2 normalisation skipped |
| 725 |
if hash_type in ('300', '1731', '1000') |
user_hash NTLM not lowercased |
| 743 |
if hash_type == '18200' |
kerberos AS-REP username is taken as line.split('$')[3] without the : split, so the stored username is the principal plus the whole ciphertext |
Two sites in the same function already carry the fix and its rationale — str(hash_type) == '2100' at line 683, and line 708 with the comment "hash_type arrives as an int on the API path (routes.py:takes <int:hash_type>)". The neighbours were missed.
The pwdump and NetNTLM branches are unaffected: pwdump hardcodes hash_type='1000' and lowercases unconditionally, and NetNTLM case-folds by field position rather than by hash type.
Blast radius
(DYNAMIC) All Recovered Passwords never gains plaintexts from API-imported hashes — the reported symptom.
(DYNAMIC) All NTLM Hashes accumulates case-duplicates of the same hash (update_dynamic_wordlist, utils.py:913).
(DYNAMIC) All Usernames is polluted with full AS-REP ciphertexts imported as usernames (line 743 above).
- Instacrack under-reports, so the API response's
instacracked count and the analytics that key off it are wrong for API uploads.
- Hashes are silently re-cracked on every job because they never flip to
cracked=1, and a job's "hashfile fully recovered" stop condition (_hashfile_has_uncracked, api/routes.py:247) can never be satisfied.
Only case-sensitive input is affected: an all-lowercase dump uploaded through the API behaves correctly today, which is why this has gone unnoticed.
Proposal
Normalise once at the top of import_hashfilehashes:
hash_type = str(hash_type)
This matches the existing house pattern in the same module — validate_* and friends already open with hash_type = str(hash_type) (utils.py:1555, :1639, :1788) — and removes the need for the two ad-hoc str(hash_type) calls at lines 683 and 708. Sprinkling more str() calls at each site would leave the same trap for the next branch added.
import_hash_only writes hash_type into an Integer column, so passing a string through is already the status quo on the UI path and needs no change.
Note this only fixes hashes imported from now on. Rows already stored uppercase by the API stay unmatchable; a follow-up data migration lowercasing ciphertext and recomputing sub_ciphertext for the affected hash_types would be needed to recover them, and should be tracked separately.
Test gap
The only existing test that passes an int (tests/unit/test_machine_account_filter.py:133) exercises just the already-fixed machine-account filter at line 708; every other test of import_hashfilehashes passes hash_type as a string, and the API-upload tests in tests/unit/test_api_routes_regression.py only exercise the invalid-file-format branch. Nothing crosses the int/str boundary, which is why the divergence is invisible to the suite.
Summary
import_hashfilehashesdecides how to normalise an incoming hash by comparinghash_typeagainst string literals. The web UI supplieshash_typeas a string (form field), so those branches fire. The API route is declared<int:hash_type>, so it supplies anintand every one of those branches is skipped.The most damaging consequence: NTLM ciphertexts uploaded through
POST /v1/hashfiles/upload/...are stored verbatim instead of lowercased. Since hashcat emits hex hashes lowercased, themd5(ciphertext)lookup that ingests crack results never matches those rows — so an API-imported NTLM hash from an uppercase dump can be cracked by an agent over and over and never be marked cracked. Its plaintext therefore never reaches the(DYNAMIC) All Recovered Passwordswordlist, and "instacrack" on import reports 0 against a corpus that already has the answer.This is the exact failure the code one branch below already warns about (
hashview/utils/utils.py:721-723):Reproduction
Against the real endpoints (in-memory SQLite,
tests/unitfixtures), uploading8846F7EAEE8FB117AD06BDD830B7586C(NTLM forpassword) asfile_format=5(hash_only),hash_type=1000:POST /v1/hashfiles/upload/<cust>/5/1000/hf8846F7EAEE8FB117AD06BDD830B7586C— the same line imported through the UI path (hash_type='1000') is stored8846f7ea…586cPOST /v1/uploadCrackFile/<jt>with8846f7ea…586c:<hex plain>cracked=False,plaintext=None— no match(DYNAMIC) All Recovered PasswordsWith a pre-existing cracked copy of that hash in the DB, the same upload returns
"instacracked": 0and creates a duplicateHashesrow, after which(DYNAMIC) All NTLM Hashescontains both the lowercase and the uppercase form of one hash.Where
hashview/utils/utils.py,import_hashfilehashes(line 665). Every site below is dead on the API path:if hash_type in ('300', '1731', '1000')hash_onlyNTLM/LM/half-NTLM not lowercasedelif hash_type == '2100'hash_onlyDCC2 not lowercased,$dcc2$→$DCC2$normalisation skippedif hash_type == '2100'hash_onlyDCC2 username never extracted (staysNULL)if hash_type == '300' or hash_type == '1731'user_hashLM/half-NTLM not lowercasedelif hash_type == '2100'user_hashDCC2 normalisation skippedif hash_type in ('300', '1731', '1000')user_hashNTLM not lowercasedif hash_type == '18200'kerberosAS-REP username is taken asline.split('$')[3]without the:split, so the stored username is the principal plus the whole ciphertextTwo sites in the same function already carry the fix and its rationale —
str(hash_type) == '2100'at line 683, and line 708 with the comment "hash_type arrives as an int on the API path (routes.py:takes<int:hash_type>)". The neighbours were missed.The
pwdumpandNetNTLMbranches are unaffected:pwdumphardcodeshash_type='1000'and lowercases unconditionally, andNetNTLMcase-folds by field position rather than by hash type.Blast radius
(DYNAMIC) All Recovered Passwordsnever gains plaintexts from API-imported hashes — the reported symptom.(DYNAMIC) All NTLM Hashesaccumulates case-duplicates of the same hash (update_dynamic_wordlist,utils.py:913).(DYNAMIC) All Usernamesis polluted with full AS-REP ciphertexts imported as usernames (line 743 above).instacrackedcount and the analytics that key off it are wrong for API uploads.cracked=1, and a job's "hashfile fully recovered" stop condition (_hashfile_has_uncracked,api/routes.py:247) can never be satisfied.Only case-sensitive input is affected: an all-lowercase dump uploaded through the API behaves correctly today, which is why this has gone unnoticed.
Proposal
Normalise once at the top of
import_hashfilehashes:This matches the existing house pattern in the same module —
validate_*and friends already open withhash_type = str(hash_type)(utils.py:1555,:1639,:1788) — and removes the need for the two ad-hocstr(hash_type)calls at lines 683 and 708. Sprinkling morestr()calls at each site would leave the same trap for the next branch added.import_hash_onlywriteshash_typeinto anIntegercolumn, so passing a string through is already the status quo on the UI path and needs no change.Note this only fixes hashes imported from now on. Rows already stored uppercase by the API stay unmatchable; a follow-up data migration lowercasing
ciphertextand recomputingsub_ciphertextfor the affectedhash_types would be needed to recover them, and should be tracked separately.Test gap
The only existing test that passes an int (
tests/unit/test_machine_account_filter.py:133) exercises just the already-fixed machine-account filter at line 708; every other test ofimport_hashfilehashespasseshash_typeas a string, and the API-upload tests intests/unit/test_api_routes_regression.pyonly exercise the invalid-file-format branch. Nothing crosses the int/str boundary, which is why the divergence is invisible to the suite.