Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ Please note:
```
usage: python3 hashtopolis.zip [-h] [--de-register] [--version] [--number-only] [--disable-update] [--debug] [--voucher VOUCHER] [--url URL]
[--cert CERT] [--files-path FILES_PATH] [--crackers-path CRACKERS_PATH] [--hashlists-path HASHLISTS_PATH]
[--preprocessors-path PREPROCESSORS_PATH] [--zaps-path ZAPS_PATH] [--cpu-only]
[--preprocessors-path PREPROCESSORS_PATH] [--zaps-path ZAPS_PATH] [--cache-path CACHE_PATH]
[--cpu-only]

Hashtopolis Client v0.7.5

Expand All @@ -57,6 +58,8 @@ options:
Use given folder path as preprocessors location
--zaps-path ZAPS_PATH
Use given folder path as zaps location
--cache-path CACHE_PATH
Use given folder path as hashcat cache location (needs hashcat support for --cache-path)
--cpu-only Force client to register as CPU only and also only reading out CPU information

```
Expand Down Expand Up @@ -98,8 +101,30 @@ When you run the client for the first time it will ask automatically for all the
| hashlists-path | string | | Use given folder path as hashlists location |
| preprocessors-path | string | | Use given folder path as preprocessors location |
| zaps-path | string | | Use given folder path as zaps location |
| cache-path | string | | Folder hashcat caches everything in (`--cache-path`, see note below) |
| cpu-only | boolean | false | Only send CPU information about agent (for CPU only agents) |

#### cache-path

hashcat keeps a seek database per wordlist, which is what makes the `--skip` of every Hashtopolis
chunk cheap, plus its compiled kernels. By default those live inside the cracker folder, so they are
cold again after every cracker binary update. `cache-path` moves them to a directory of your choice.

Seek databases are named after a hash of the wordlist they describe, so several agents pointed at
one shared mount build each database only once for the whole cluster.

Notes:

* Use a **local, writable** directory. hashcat tolerates a read-only location for the wordlist
caches, but a failed kernel cache write aborts the session. To share across agents, point
`cache-path` at a local directory and mount or symlink only `cache/feeds/wordlist` to the share.
* The option is only passed to crackers new enough for it. It appears in hashcat at build
`7.1.2-627-g6570a49f2`, so the agent passes it to that build and later, and to any release after
7.1.2. Anything older, including the 7.1.2 release itself, stays silent and is unaffected.
Comment on lines +121 to +123
* The agent creates the directory at startup if it is missing. If it points at a mount that is
down, that creates an empty local directory shadowing the mountpoint - check the log.
* It is not removed when the agent de-registers, since it may be shared with other agents.

### Debug example

```
Expand Down Expand Up @@ -128,6 +153,7 @@ In order to use the multicast distribution for files, please make sure that the

The list contains all Hashcat versions with which the client was tested and is able to work with (other versions might work):

* 7.1.2
* 7.1.1
* 7.1.0
* 7.0.0
Expand Down
9 changes: 9 additions & 0 deletions __main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ def init(args):
if args.preprocessors_path and len(args.preprocessors_path):
CONFIG.set_value('preprocessors-path', os.path.abspath(args.preprocessors_path))

# cache-path has no default on purpose: when it is unset hashcat keeps using its own cache location
if args.cache_path and len(args.cache_path):
CONFIG.set_value('cache-path', args.cache_path)
if len(CONFIG.get_value('cache-path')) > 0:
# hashcat is started with cwd=crackers/<id>, so a relative path would end up inside the cracker folder
CONFIG.set_value('cache-path', os.path.abspath(CONFIG.get_value('cache-path')))

logging.info("Starting client '" + Initialize.get_version() + "'...")

# check if there are running hashcat.pid files around (as we assume that nothing is running anymore if the client gets newly started)
Expand Down Expand Up @@ -305,6 +312,7 @@ def de_register():
else:
logging.info("Successfully de-registered!")
# cleanup
# cache-path is left alone on purpose: it is meant to be shared between agents and is rebuildable
dirs = [CONFIG.get_value('crackers-path'), CONFIG.get_value('preprocessors-path'), CONFIG.get_value('hashlists-path'), CONFIG.get_value('files-path')]
files = ['config.json', '7zr.exe', '7zr']
for file in files:
Expand Down Expand Up @@ -334,6 +342,7 @@ def de_register():
parser.add_argument('--hashlists-path', type=str, required=False, help='Use given folder path as hashlists location')
parser.add_argument('--preprocessors-path', type=str, required=False, help='Use given folder path as preprocessors location')
parser.add_argument('--zaps-path', type=str, required=False, help='Use given folder path as zaps location')
parser.add_argument('--cache-path', type=str, required=False, help='Use given folder path as hashcat cache location (needs hashcat support for --cache-path)')
parser.add_argument('--cpu-only', action='store_true', help='Force client to register as CPU only and also only reading out CPU information')
args = parser.parse_args()

Expand Down
9 changes: 9 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
> [!NOTE]
> Changelog will not be updated anymore, use the github releases section to get information about changes.

## v0.7.4 -> v0.7.5

### Enhancements

* New `cache-path` config option and `--cache-path` command line argument to set the folder hashcat
caches wordlist seek databases and kernels in. Several agents can share one location. Only sent to
crackers which support `--cache-path`.


## v0.7.3-> v0.7.4

### Bugfixes
Expand Down
132 changes: 102 additions & 30 deletions htpclient/hashcat_cracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,73 @@
from htpclient.dicts import *


# Where hashcat keeps the per-wordlist seek databases that make --skip cheap, and the compiled
# kernels.
CACHE_PATH_FLAG = '--cache-path'

# The release the option landed after, and the build on that release which first carried it. It
# appears in src/usage.c at v7.1.2-627-g6570a49f2; the build before it has no such option. Once a
# release ships with it, this becomes that release and the build number stops mattering.
CACHE_PATH_RELEASE = (7, 1, 2, 627)

def parse_version(version_string):
"""Turn the version a cracker reports into a tuple that compares the way the versions do.

"7.1.2" becomes (7, 1, 2) and a build made after a release, "7.1.2-627-g6570a49f2", becomes
(7, 1, 2, 627); the commit hash after the build number is dropped. A release sorts before every
build made on it, so plain tuple comparison against a minimum like (5, 1, 0, 1618) works.

Returns None when the string cannot be read as a version, so each caller decides what an
unknown version means for it.
"""
if not version_string:
return None
parts = version_string.strip().split('-')
try:
version = tuple(int(s) for s in parts[0].split('.'))
if len(parts) > 1:
version += (int(parts[1]),)
except ValueError:
return None
return version

def version_supports_cache_path(version_string):
"""Whether the version a cracker reports is one that knows --cache-path.

The version arrives as either "7.1.2" or "7.1.2-627-g6570a49f2", which is how hashcat spells a
build made after a release, as read by parse_version(). The option landed
part way through the builds that followed 7.1.2, so on that release it is the build number that
decides, and any later release has it outright.

Anything that cannot be read as a version is treated as too old. hashcat does not ignore an
option it does not know, it stops, so the safe answer when in doubt is to say nothing.
"""
version = parse_version(version_string)
return version is not None and version > CACHE_PATH_RELEASE

def select_cache_arg(cache_path, version_string, isdir=os.path.isdir):
"""Build the hashcat arguments for the configured cache directory.

Returns an empty list when nothing is configured, when the cracker is too old for the option, or
when the directory is not there right now. Both an unknown option and a missing cache directory
make hashcat abort immediately, and a cache is a speed-up, never worth failing a chunk over - so
we drop the option and let hashcat fall back to its own cache location.
"""
if not cache_path:
return []
if '"' in cache_path:
# the value is quoted, not escaped, when it goes into the shell command line
logging.debug(f"Ignoring cache-path '{cache_path}': the path must not contain a double quote")
return []
if version_supports_cache_path(version_string) is False:
logging.debug(f"Ignoring cache-path '{cache_path}': cracker version {version_string.strip() if version_string else '<unknown>'} does not support {CACHE_PATH_FLAG}")
return []
if not isdir(cache_path):
logging.debug(f"Ignoring cache-path '{cache_path}': not a directory, hashcat will use its own cache location")
return []
return [f'{CACHE_PATH_FLAG}="{cache_path}"']


class HashcatCracker:
def __init__(self, cracker_id, binary_download):
self.config = Config()
Expand Down Expand Up @@ -63,26 +130,23 @@ def __init__(self, cracker_id, binary_download):
self.wasStopped = False

def get_outfile_format(self):
if self.version_string.find('-') == -1:
release = self.version_string.split('.')
try:
if int(str(release[0])) >= 6:
return "1,2,3,4"
except ValueError:
return "1,2,3,4" # if there is a custom version, we assume it's using the new format
return "15" # if we cannot determine the version or if the release is older than 6.0.0, we will use the old format
split = self.version_string.split('-')
if len(split) < 2:
return "15" # something is wrong with the version string, go for old format
release = str(split[0]).split('.')
commit = str(split[1])
if int(str(release[0])) < 5:
return "15"
elif int(str(release[0])) == 5 and int(str(release[1])) < 1:
return "15"
elif int(str(release[0])) == 5 and int(str(release[1])) == 1 and int(str(release[2])) == 0 and int(commit) < 1618:
return "15"
return "1,2,3,4" # new outfile format
version = parse_version(self.version_string)
if version is None:
return "1,2,3,4" # if there is a custom version, we assume it's using the new format
if version >= (5, 1, 0, 1618):
return "1,2,3,4" # new outfile format
return "15"

def get_cache_args(self):
"""Arguments pointing hashcat at the configured cache directory, empty list if unavailable."""
cache_path = self.config.get_value('cache-path')
if not cache_path:
return []
return select_cache_arg(cache_path, self.version_string)

def get_cache_args_str(self):
"""get_cache_args() for the builders that concatenate strings: leading space, or empty."""
return ''.join(f' {arg}' for arg in self.get_cache_args())

def build_command(self, task, chunk):
args = []
Expand Down Expand Up @@ -120,6 +184,7 @@ def build_command(self, task, chunk):
files = update_files(task['attackcmd'])
files = files.replace(task['hashlistAlias'], f'"{hashlist_file}"')
args.append(files)
args.extend(self.get_cache_args())
args.append(task['cmdpars'])


Expand All @@ -136,13 +201,15 @@ def build_pipe_command(self, task, chunk):
# call the command with piping
pre_args = " --stdout -s " + str(chunk['skip']) + " -l " + str(chunk['length']) + ' '
pre_args += update_files(task['attackcmd']).replace(task['hashlistAlias'], '')
pre_args += self.get_cache_args_str()
post_args = " --machine-readable --quiet --status --remove --restore-disable --potfile-disable --session=hashtopolis"
post_args += " --status-timer " + str(task['statustimer'])
post_args += " --outfile-check-timer=" + str(task['statustimer'])
post_args += " --outfile-check-dir='" + self.config.get_value('zaps-path') + "/hashlist_" + str(task['hashlistId']) + "'"
post_args += " -o '" + self.config.get_value('hashlists-path') + "/" + str(task['hashlistId']) + ".out' --outfile-format=" + self.get_outfile_format() + " -p \"" + str(chr(9)) + "\""
post_args += " --remove-timer=" + str(task['statustimer'])
post_args += " '" + self.config.get_value('hashlists-path') + "/" + str(task['hashlistId']) + "'"
post_args += self.get_cache_args_str()
return f"'{self.callPath}'" + pre_args + " | " + f"'{self.callPath}'" + post_args + task['cmdpars']

# DEPRECATED
Expand Down Expand Up @@ -213,6 +280,7 @@ def build_preprocessor_command(self, task, chunk, preprocessor):
files = update_files(task['attackcmd'])
files = files.replace(task['hashlistAlias'] + " ", "")
post_args.append(files)
post_args.extend(self.get_cache_args())
post_args.append(task['cmdpars'])

pre_args = ' '.join(pre_args)
Expand Down Expand Up @@ -291,6 +359,10 @@ def run_loop(self, proc, chunk, task):
piping_threshold = self.config.get_value('piping-threshold')
if self.config.get_value('allow-piping') != '':
enable_piping = self.config.get_value('allow-piping')
# before hashcat 7.1.2-546, the PROGRESS fields included the skip value. A version we cannot
# read is assumed to be a recent custom build, as get_outfile_format() does.
version = parse_version(self.version_string)
progress_includes_skip = version is not None and version < (7, 1, 2, 546)
while True:
try:
# Block for 1 second.
Expand All @@ -316,10 +388,6 @@ def run_loop(self, proc, chunk, task):
else:
identifier, line = item
if identifier == 'OUT':
# "7.1.2" -> (7,1,2)
# "7.1.2-546-gc885beef" -> (7,1,2,546)
version_list = self.version_string.replace('-', '.').split('.')[:4] # Chop off anything past the fourth field (for example commit hash)
version_tuple = tuple(int(s) for s in version_list)
status = HashcatStatus(line.decode())
if status.is_valid():
self.statusCount += 1
Expand Down Expand Up @@ -348,12 +416,11 @@ def run_loop(self, proc, chunk, task):
# we need to calculate the chunk start, because progress does not start at 0 for a chunk
chunk_start = int(status.get_progress_total() / (chunk['skip'] + chunk['length']) * chunk['skip'])
if total > 0:
if version_tuple >= (7, 1, 2, 546):
relative_progress = int(status.get_progress() / total * 10000)
else:
# before hashcat 7.1.2-546, the PROGRESS fields included the skip value.
# Adjust for this offset
if progress_includes_skip:
# Adjust for the skip offset in the PROGRESS fields
relative_progress = int((status.get_progress() - chunk_start) / float(total - chunk_start) * 10000)
else:
relative_progress = int(status.get_progress() / total * 10000)
else: # this is the case when we cannot say anything about the progress
relative_progress = 0
speed = status.get_speed()
Expand Down Expand Up @@ -456,7 +523,7 @@ def measure_keyspace(self, task, chunk):
files = update_files(task['attackcmd'])
files = files.replace(task['hashlistAlias'] + " ", "")

full_cmd = f"{self.callPath} --keyspace --quiet {files} {task['cmdpars']}"
full_cmd = f"{self.callPath} --keyspace --quiet {files}{self.get_cache_args_str()} {task['cmdpars']}"

if 'useBrain' in task and task['useBrain']:
full_cmd = f"{full_cmd} -S"
Expand Down Expand Up @@ -581,6 +648,7 @@ def run_benchmark(self, task):
files = files.replace(task['hashlistAlias'], f'"{hashlist_path}"')

args.append(files)
args.extend(self.get_cache_args())
args.append(task['cmdpars'])
args.append('-o')
args.append(f'"{hashlist_out_path}"')
Expand Down Expand Up @@ -662,6 +730,7 @@ def run_speed_benchmark(self, task):
attackcmd = attackcmd.replace(task['hashlistAlias'], f'"{hashlist_path}"')

args.append(attackcmd)
args.extend(self.get_cache_args())
args.append(task['cmdpars'])
if 'usePreprocessor' in task and task['usePreprocessor']:
args.append('example.dict')
Expand Down Expand Up @@ -735,6 +804,9 @@ def run_health_check(self, attack, hashlist_alias):
args.append('--restore-disable')
args.append('--potfile-disable')
args.append('--session=health')
# this ends up in the Windows slash rewrite of full_cmd below, which is harmless: on Windows
# os.path.abspath already hands us a backslash path
args.extend(self.get_cache_args())
attackcmd = update_files(attack)
hashlist_path = Path(self.config.get_value('hashlists-path'), "health_check.txt")
hashlist_out_path = Path(self.config.get_value('hashlists-path'), "health_check.out")
Expand Down
11 changes: 11 additions & 0 deletions htpclient/initialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,14 @@ def __build_directories(self):
os.makedirs(self.config.get_value('hashlists-path'))
if not os.path.isdir(self.config.get_value('preprocessors-path')):
os.makedirs(self.config.get_value('preprocessors-path'))
# hashcat does not create the cache directory it is handed and aborts when it is missing, so we
# create it here. It may be a read-only or currently unavailable mount, which must not stop the
# agent from starting - the cracker checks again per chunk and drops the option if it is gone.
# exist_ok, because this is the one directory here that several agents may be sharing: two of
# them starting together both find it missing and both go on to create it.
cache_path = self.config.get_value('cache-path')
if cache_path and not os.path.isdir(cache_path):
try:
os.makedirs(cache_path, exist_ok=True)
except OSError as e:
logging.warning("Could not create cache directory '" + cache_path + "': " + str(e))
Loading
Loading