From 347e3529d580a6ff8ac21e9673355c0c002590ef Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 18 Jun 2026 17:44:48 -0400 Subject: [PATCH] feat(hashview): display a customer's hashfiles instead of asking for type Downloading previously required knowing/typing the hash type because Hashview only lists hashfiles per type. The download flow now sweeps common hashcat modes (HashviewAPI.get_all_customer_hashfiles) and shows all of the customer's uploaded hashfiles to pick from, deduped by id. A manual hash-type prompt remains as a fallback only when nothing is found among the common set (uncommon modes). The chosen file's real hash type still drives the download. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- hate_crack/api.py | 69 ++++++++++++++++++++++++++++++++++++++++++ hate_crack/main.py | 57 ++++++++++++++++++++-------------- tests/test_hashview.py | 35 +++++++++++++++++++++ 4 files changed, 139 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 9e2ce22..b086e87 100644 --- a/README.md +++ b/README.md @@ -913,7 +913,7 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ### Version History Version 2.10.6 - - Fixed the Hashview integration calling API routes that don't exist in Hashview (verified against v0.8.3-dev), which 404'd as soon as a customer ID was entered ("Could not list hashfiles"). The customer→hashfile listing relied on a phantom `GET /v1/hashfiles` list-all route; it now enumerates via the real `GET /v1/hashfiles/hash_type/` endpoint, scoped to the session hash type (prompting for a hash type when none is loaded this session), and filters by customer. Additional client-side route fixes: hashfile hash-type lookup now uses `GET /v1/getHashType/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `start_job` uses `POST`. Hashview exposes no stop-job route, so `stop_job` now raises with guidance to use `delete_job`; and no bulk cracked-hash export exists, so the best-effort "found" merge degrades gracefully + - Fixed the Hashview integration calling API routes that don't exist in Hashview (verified against v0.8.3-dev), which 404'd as soon as a customer ID was entered ("Could not list hashfiles"). The customer→hashfile listing relied on a phantom `GET /v1/hashfiles` list-all route; it now enumerates via the real `GET /v1/hashfiles/hash_type/` endpoint. Since that route lists per hash type, the download flow sweeps common hashcat modes to display all of a customer's uploaded hashfiles (falling back to a manual hash-type prompt only if none are found among the common set), then filters by customer. Additional client-side route fixes: hashfile hash-type lookup now uses `GET /v1/getHashType/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `start_job` uses `POST`. Hashview exposes no stop-job route, so `stop_job` now raises with guidance to use `delete_job`; and no bulk cracked-hash export exists, so the best-effort "found" merge degrades gracefully Version 2.10.5 - Pipal analysis no longer corrupts its input when cracked passwords contain `$HEX[...]` rows. `binascii.unhexlify().decode()` returned the bytes without the trailing newline that normal rows inherit from `password[-1]`, so every HEX-encoded password got concatenated with the next one in the `.passwords` file fed to pipal (e.g. three cracks → two lines, one of them a bogus mashup). Pipal then under-counted entries and reported wrong top base words. The HEX branch now re-appends `\n` so each cracked password lands on its own line diff --git a/hate_crack/api.py b/hate_crack/api.py index 72b69c8..c0eec96 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -1211,6 +1211,75 @@ class HashviewAPI: return customer_hfs + # Curated set of hashcat modes commonly seen in engagements. Used to + # enumerate a customer's hashfiles by sweeping the per-type listing + # endpoint, since Hashview exposes no list-all route. Not exhaustive: + # uncommon types can still be queried explicitly via get_customer_hashfiles. + COMMON_HASH_TYPES = ( + 0, # MD5 + 100, # SHA1 + 1000, # NTLM + 3000, # LM + 1100, # Domain Cached Credentials (DCC), MS Cache + 2100, # Domain Cached Credentials 2 (DCC2), MS Cache 2 + 5500, # NetNTLMv1 + 5600, # NetNTLMv2 + 7500, # Kerberos 5 AS-REQ Pre-Auth (etype 23) + 13100, # Kerberos 5 TGS-REP (Kerberoasting, etype 23) + 18200, # Kerberos 5 AS-REP (AS-REP roasting, etype 23) + 19600, # Kerberos 5 TGS-REP (etype 17) + 19700, # Kerberos 5 TGS-REP (etype 18) + 1800, # sha512crypt $6$ (Linux) + 500, # md5crypt $1$ (Linux/Cisco) + 7400, # sha256crypt $5$ + 3200, # bcrypt $2*$ + 1700, # SHA512 + 1400, # SHA256 + 160, # HMAC-SHA1 + 13400, # KeePass + 9600, # MS Office 2013 + 10500, # PDF 1.4-1.6 + 11600, # 7-Zip + 16500, # JWT + 22000, # WPA-PBKDF2-PMKID+EAPOL + ) + + def get_all_customer_hashfiles(self, customer_id, hash_types=None): + """Aggregate a customer's hashfiles across hash types (deduped by id). + + Hashview has no list-all route, so this sweeps the per-type listing + endpoint (:meth:`get_hashfiles_by_type`) over ``hash_types`` and keeps + the files belonging to ``customer_id``. Defaults to + :attr:`COMMON_HASH_TYPES`; a file of an uncommon type not in the sweep + won't appear. The first hash type a file is seen under is retained as + its ``hash_type`` (mixed-type files are rare). + """ + if hash_types is None: + hash_types = self.COMMON_HASH_TYPES + seen = {} + for ht in hash_types: + try: + files = self.get_hashfiles_by_type(ht) + except Exception as e: + if self.debug: + print(f"[DEBUG] get_all_customer_hashfiles: type {ht} failed: {e}") + continue + for hf in files: + if int(hf.get("customer_id", 0)) != int(customer_id): + continue + hf_id = hf.get("id") + if hf_id is None: + continue + if not (hf.get("hashtype") or hf.get("hash_type")): + hf["hash_type"] = str(ht) + seen.setdefault(int(hf_id), hf) + if self.debug: + print( + f"[DEBUG] get_all_customer_hashfiles({customer_id}): " + f"found {len(seen)} hashfiles across {len(hash_types)} types" + ) + return list(seen.values()) + def get_customer_hashfiles_with_hashtype(self, customer_id, target_hashtype="1000"): """Return hashfiles for a customer that match the requested hashtype.""" customer_hashfiles = self.get_customer_hashfiles( diff --git a/hate_crack/main.py b/hate_crack/main.py index 88802d9..bed09ac 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3759,34 +3759,45 @@ def hashview_api(): continue # List hashfiles for the customer. Hashview has no - # list-all route, so we must enumerate by hash type. - # Prefer the session hash type (set when a hashfile is - # loaded); otherwise prompt, since None can't be queried. - download_hash_type = hcatHashType - if not download_hash_type: - ht_input = input( - "\nNo hash type loaded this session. Enter the hashcat " - "hash-type to list (e.g. 1000 for NTLM), or Q to cancel: " - ).strip() - if ht_input.lower() == "q": - cancel_download = True - break - if not ht_input.isdigit(): - print( - "\n✗ Error: Hash type must be a numeric hashcat mode." - ) - continue - download_hash_type = ht_input - + # list-all route, so we enumerate by sweeping the + # per-type endpoint across common hashcat modes and + # showing whatever the customer has uploaded. try: - customer_hashfiles = api_harness.get_customer_hashfiles( - customer_id, hash_type=download_hash_type + print( + "\nScanning customer hashfiles across common hash types..." ) + customer_hashfiles = api_harness.get_all_customer_hashfiles( + customer_id + ) + + # Fallback: nothing among common types. The file may + # use an uncommon mode, so let the user name it. + if not customer_hashfiles: + print( + f"\nNo hashfiles found for customer ID {customer_id} " + "among common hash types." + ) + ht_input = input( + "Enter a specific hashcat hash-type to check " + "(e.g. 1000), or Q to cancel: " + ).strip() + if ht_input.lower() == "q": + cancel_download = True + break + if not ht_input.isdigit(): + print( + "\n✗ Error: Hash type must be a numeric hashcat mode." + ) + continue + customer_hashfiles = ( + api_harness.get_customer_hashfiles( + customer_id, hash_type=ht_input + ) + ) if not customer_hashfiles: print( - f"\nNo hashfiles of type {download_hash_type} found " - f"for customer ID {customer_id}" + f"\nNo hashfiles found for customer ID {customer_id}" ) continue diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 76d556a..1e092b4 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -95,6 +95,41 @@ class TestHashviewAPI: result = api.get_customer_hashfiles(1) assert result == [] + def test_get_all_customer_hashfiles_sweeps_and_dedupes(self, api): + """Aggregate sweeps per-type listings, filters by customer, dedupes by id.""" + per_type = { + 1000: [ + {"id": 1, "customer_id": 1, "name": "ntlm.txt", "hash_type": 1000}, + {"id": 2, "customer_id": 2, "name": "other.txt", "hash_type": 1000}, + ], + 5600: [ + {"id": 3, "customer_id": 1, "name": "ntlmv2.txt", "hash_type": 5600}, + # id 1 appears again under another type; must dedupe (first wins) + {"id": 1, "customer_id": 1, "name": "ntlm.txt", "hash_type": 5600}, + ], + } + api.get_hashfiles_by_type = Mock(side_effect=lambda ht: per_type.get(int(ht), [])) + + result = api.get_all_customer_hashfiles(1, hash_types=[1000, 5600]) + + ids = sorted(hf["id"] for hf in result) + assert ids == [1, 3] # customer 2 excluded, id 1 not duplicated + by_id = {hf["id"]: hf for hf in result} + assert str(by_id[1]["hash_type"]) == "1000" # first type seen wins + assert str(by_id[3]["hash_type"]) == "5600" + + def test_get_all_customer_hashfiles_skips_failing_types(self, api): + """A per-type query that errors is skipped, not fatal.""" + + def _by_type(ht): + if int(ht) == 1000: + raise RuntimeError("boom") + return [{"id": 9, "customer_id": 1, "name": "x", "hash_type": int(ht)}] + + api.get_hashfiles_by_type = Mock(side_effect=_by_type) + result = api.get_all_customer_hashfiles(1, hash_types=[1000, 5600]) + assert [hf["id"] for hf in result] == [9] + def test_get_customer_hashfiles(self, api): """Filter the type-scoped hashfile list by customer_id (real API if possible).""" hashview_url, hashview_api_key = self._get_hashview_config()