Skip to content

Hashfile import: hash_type is compared to strings, so the API path (int) skips all normalisation — NTLM hashes stored uppercase are never marked cracked #444

Description

@bandrel

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    JobsWordlistsbugSomething isn't workingpythonPull requests that update python code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions