From 695616edad56b8aa815d31cd18526714f3a0e8f2 Mon Sep 17 00:00:00 2001 From: Andrew Horton Date: Tue, 11 Aug 2026 16:51:36 +1000 Subject: [PATCH 1/2] Fix agent going inactive on --increment speed benchmarks hashcat refuses to run --progress-only together with --increment ("Increment is not allowed in combination with --progress-only."), so the speed benchmark exited non-zero for every increment task. The agent sent a clientError, and the server deactivates an agent on its first error when ignoreErrors=0 (the default), leaving the agent stuck Inactive until re-enabled by hand. Strip the increment flags before running --progress-only; the measured cracking speed is independent of the increment range, so the benchmark stays valid. Add strip_increment() with unit tests. strip_increment iterates the tokens directly and must NOT route them through clean_list(): the assembled benchmark command contains runs of spaces, and clean_list() deletes real tokens when it hits consecutive empty entries (it mutates the list while iterating it). That bug dropped the attack mask entirely (e.g. -a 3 ?d?d?d?d?d became bare -a 3), after which hashcat fell back to its default ?1 mask and failed with "Custom-charset 1 is undefined". Also capture hashcat's real output on failure: 'output' was reset to b'' before the try block, so CalledProcessError.output was discarded and the error log line was always empty. Read e.output instead. Co-Authored-By: Claude Opus 4.8 --- htpclient/hashcat_cracker.py | 8 +++++-- htpclient/helpers.py | 24 ++++++++++++++++++++ tests/test_helpers.py | 43 ++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/test_helpers.py diff --git a/htpclient/hashcat_cracker.py b/htpclient/hashcat_cracker.py index 06f900b..b8332e6 100644 --- a/htpclient/hashcat_cracker.py +++ b/htpclient/hashcat_cracker.py @@ -13,7 +13,7 @@ from htpclient.hashcat_status import HashcatStatus from htpclient.initialize import Initialize from htpclient.jsonRequest import JsonRequest, os -from htpclient.helpers import send_error, update_files, kill_hashcat, get_bit, print_speed, get_rules_and_hl, get_wordlist, escape_ansi +from htpclient.helpers import send_error, update_files, kill_hashcat, get_bit, print_speed, get_rules_and_hl, get_wordlist, escape_ansi, strip_increment from htpclient.dicts import * @@ -663,13 +663,17 @@ def run_speed_benchmark(self, task): args.append(f'"{hashlist_out_path}"') full_cmd = ' '.join(args) + # hashcat rejects --progress-only combined with --increment, which would + # make the speed benchmark fail for every increment task. Drop the + # increment flags: the measured speed is independent of the mask length. + full_cmd = strip_increment(full_cmd) full_cmd = f"{self.callPath} {full_cmd}" - output = b'' try: logging.debug(f"CALL: {''.join(full_cmd)}") output = subprocess.check_output(full_cmd, shell=True, cwd=self.cracker_path, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: + output = e.output if e.output else b'' logging.error("Error during speed benchmark, return code: " + str(e.returncode) + " Output: " + output.decode(encoding='utf-8')) send_error("Speed benchmark failed!", self.config.get_value('token'), task['taskId'], None) return 0 diff --git a/htpclient/helpers.py b/htpclient/helpers.py index 2698cbe..926e892 100644 --- a/htpclient/helpers.py +++ b/htpclient/helpers.py @@ -105,6 +105,30 @@ def get_rules_and_hl(command, alias): return " ".join(rules) +# hashcat rejects --increment together with --progress-only, so the speed +# benchmark can't run it. Remove the increment flags; cracking speed does not +# depend on the increment range. Note: do not run the tokens through clean_list +# here - the assembled command contains runs of spaces, and clean_list drops +# real tokens when it hits consecutive empty entries. Empty tokens are harmless +# (they rejoin as spaces), so just iterate as-is. +def strip_increment(command): + ret = [] + skip_next = False + for part in command.split(" "): + if skip_next: + skip_next = False + continue + if part == '--increment' or part == '-i': + continue + if part == '--increment-min' or part == '--increment-max': + skip_next = True + continue + if part.startswith('--increment-min=') or part.startswith('--increment-max='): + continue + ret.append(part) + return " ".join(ret) + + def clean_list(element_list): index = 0 for part in element_list: diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..257c0d1 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,43 @@ +import unittest + +from htpclient.helpers import strip_increment + + +class StripIncrement(unittest.TestCase): + def test_removes_increment_with_equals_values(self): + cmd = '#HL# -a 3 ?1 --increment --increment-min=1 --increment-max=6 -1 ?l?u?d?s' + expected = '#HL# -a 3 ?1 -1 ?l?u?d?s' + self.assertEqual(strip_increment(cmd), expected) + + def test_removes_increment_with_space_separated_values(self): + cmd = '#HL# -a 3 ?1 --increment --increment-min 1 --increment-max 6 -1 ?l?u?d?s' + expected = '#HL# -a 3 ?1 -1 ?l?u?d?s' + self.assertEqual(strip_increment(cmd), expected) + + def test_removes_short_increment_flag(self): + cmd = '#HL# -a 3 ?d?d?d?d -i' + expected = '#HL# -a 3 ?d?d?d?d' + self.assertEqual(strip_increment(cmd), expected) + + def test_leaves_command_without_increment_untouched(self): + cmd = '#HL# -a 0 example.dict -r best64.rule' + self.assertEqual(strip_increment(cmd), cmd) + + def test_does_not_touch_unrelated_flags(self): + cmd = '#HL# -a 3 ?a?a?a --increment -w 3' + expected = '#HL# -a 3 ?a?a?a -w 3' + self.assertEqual(strip_increment(cmd), expected) + + def test_preserves_mask_with_runs_of_spaces(self): + # The assembled benchmark command contains runs of spaces; the mask and + # every other real token must survive (regression: a mask like ?d?d?d?d?d + # was being dropped, leaving -a 3 with no mask). + cmd = '-a 3 "/hashlists/11" ?d?d?d?d?d --hash-type=500 -o "/out"' + result = strip_increment(cmd) + self.assertIn('?d?d?d?d?d', result.split()) + self.assertIn('--hash-type=500', result.split()) + self.assertIn('-o', result.split()) + + +if __name__ == '__main__': + unittest.main() From 46c32491dafa87e204819f34977f2417acd21e02 Mon Sep 17 00:00:00 2001 From: Andrew Horton Date: Mon, 17 Aug 2026 16:28:58 +1000 Subject: [PATCH 2/2] Fix related command/benchmark robustness bugs in the agent Found while fixing the speed-benchmark increment issue: * clean_list() deleted entries from a list while iterating it, which skips elements and leaves real tokens (and empty strings) behind. Rewrite it as a filtered comprehension. This also stops get_wordlist() from raising IndexError on the empty strings clean_list used to leave. * measure_keyspace() reset 'output' to b'' before the try, so on failure it logged an always-empty Output and discarded hashcat's real message in CalledProcessError.output. Read e.output, matching run_speed_benchmark. * run_speed_benchmark()'s PRINCE branch called get_rules_and_hl() with only one argument; the function requires (command, alias), so it raised TypeError. Pass the hashlist alias, as the preprocessor path already does. Co-Authored-By: Claude Opus 4.8 --- htpclient/hashcat_cracker.py | 4 ++-- htpclient/helpers.py | 19 +++++++------------ tests/test_helpers.py | 10 +++++++++- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/htpclient/hashcat_cracker.py b/htpclient/hashcat_cracker.py index b8332e6..6075326 100644 --- a/htpclient/hashcat_cracker.py +++ b/htpclient/hashcat_cracker.py @@ -452,11 +452,11 @@ def measure_keyspace(self, task, chunk): if 'useBrain' in task and task['useBrain']: full_cmd = f"{full_cmd} -S" - output = b'' try: logging.debug(f"CALL: {full_cmd}") output = subprocess.check_output(full_cmd, shell=True, cwd=self.cracker_path, stderr=subprocess.STDOUT) except subprocess.CalledProcessError as e: + output = e.output if e.output else b'' logging.error("Error during keyspace measure: " + str(e) + " Output: " + output.decode(encoding='utf-8')) send_error("Keyspace measure failed!", self.config.get_value('token'), task['taskId'], None) sleep(5) @@ -637,7 +637,7 @@ def run_speed_benchmark(self, task): hashlist_out_path = Path(self.config.get_value('hashlists-path'), f"{str(task['hashlistId'])}.out") if 'usePrince' in task and task['usePrince']: - attackcmd = get_rules_and_hl(update_files(task['attackcmd'])) + attackcmd = get_rules_and_hl(update_files(task['attackcmd']), task['hashlistAlias']) # Replace #HL# with the real hashlist attackcmd = attackcmd.replace(task['hashlistAlias'], f'"{hashlist_path}"') diff --git a/htpclient/helpers.py b/htpclient/helpers.py index 926e892..95948ce 100644 --- a/htpclient/helpers.py +++ b/htpclient/helpers.py @@ -106,11 +106,10 @@ def get_rules_and_hl(command, alias): # hashcat rejects --increment together with --progress-only, so the speed -# benchmark can't run it. Remove the increment flags; cracking speed does not -# depend on the increment range. Note: do not run the tokens through clean_list -# here - the assembled command contains runs of spaces, and clean_list drops -# real tokens when it hits consecutive empty entries. Empty tokens are harmless -# (they rejoin as spaces), so just iterate as-is. +# benchmark can't run it. Drop the increment flags; the measured cracking speed +# is independent of the mask length / increment range. Iterate the tokens +# directly and keep any empty ones - they rejoin as harmless spaces, so there is +# nothing to filter out. def strip_increment(command): ret = [] skip_next = False @@ -130,13 +129,9 @@ def strip_increment(command): def clean_list(element_list): - index = 0 - for part in element_list: - if not part: - del element_list[index] - index -= 1 - index += 1 - return element_list + # Drop empty entries. Must not delete while iterating the same list - + # that skips elements and leaves real tokens behind. + return [part for part in element_list if part] # the prince flag is deprecated diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 257c0d1..c483648 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,6 +1,14 @@ import unittest -from htpclient.helpers import strip_increment +from htpclient.helpers import strip_increment, clean_list + + +class CleanList(unittest.TestCase): + def test_removes_all_empty_entries_including_consecutive_ones(self): + self.assertEqual(clean_list(['a', '', '', 'b', '', 'c']), ['a', 'b', 'c']) + + def test_leaves_full_list_untouched(self): + self.assertEqual(clean_list(['-a', '3', '?d?d']), ['-a', '3', '?d?d']) class StripIncrement(unittest.TestCase):