diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..910eff8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.swp +*.pyc +__pycache__ +*pyhashcat/ +*.log +*crackq.log* +*.cracked +*.hashes +docker/build/ +*egg-info* +build* +dist/ +.pytest_cache +crackq.egg-info +dist/ +old/ diff --git a/LICENCE.txt b/LICENCE.txt new file mode 100644 index 0000000..b2e1889 --- /dev/null +++ b/LICENCE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 SpiderLabs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f67b8a2 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ + +**CrackQ_client: REST API client for CrackQ** +------------------------ +------- +**INSTALLATION** +----------------- +*git clone https://github.com/f0cker/crackq_client.git* + +*cd ./crackq_client* + +*pip3 install .* + +Or install from PyPi with: + +*pip3 install crackq_client* + +This will install the package, providing the client ('crackq') in your path. + +------- +**USER GUIDE** +-------------------- +To use the queue a Python client is provided. + +Authentication is performed using a JSON post. + +Some example uses are outlined below: + +------- +**Auth** + +login: + +*crackq --url https://crackq.xxx.com -l --user test --passwd test* + +or enter the password securely with a prompt: + +*crackq --url https://crackq.xxx.com -l --user test* + +logout: + +*crackq --url https://crackq.xxx.com -L* + +------- + +**Queries** + +view available Hashcat options (rules, wordlists, hash modes etc): + +*crackq --url https://crackq.xxx.com -o* + +query the queue: + +*crackq --url https://crackq.xxx.com -q* + +query failed queue: + +*crackq --url https://crackq.xxx.com -f* + +get details for a job: + +*crackq --url https://crackq.xxx.com -j --job_id f210b58b7a214d33813051a550cbf3e4* + +query complete queue: + +*crackq --url https://crackq.xxx.com -c* + +------- + +**Actions** + +add jobs: + +wordlist/rules: + +*crackq -a --attack_mode 0 --hash_mode 1000 --hash_file deadbeef.hashes --wordlist tw_leaks --url https://crackq.xxx.com --rules d3ad0ne --name dt_test_nt_twl_d3ad* + +brute force: + +*crackq -a --attack_mode 3 --hash_mode 1000 --hash_file deadbeef.hashes --mask ?u?a?a?l?l?l?a?a --url https://crackq.xxx.com --name dt_test_nt_brute* + +stop/pause a job: + +*crackq --url https://crackq.xxx.com -s --job_id * + +stop/delete a job: + +*crackq --url https://crackq.xxx.com -d --job_id * + +restore a job: + +*crackq --url https://crackq.xxx.com -r --job_id * + diff --git a/crackq_client/__init__.py b/crackq_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crackq_client/client.py b/crackq_client/client.py new file mode 100755 index 0000000..a287168 --- /dev/null +++ b/crackq_client/client.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- + +""" +Python client for interacting with CrackQ REST API. + +A queuing system for multi-user hash cracking using Hashcat. + +Author: dturner@trustwave (@f0cker_) +""" + +import argparse +import getpass +import json +import os +import logging +import pprint +import sys +import crackq_client.client_rest as client_rest + +if sys.version_info.major < 3: + print('Crackqcli requires Python version 3') + exit(1) + +from pathlib import Path + +os.umask(0o077) + +BANNER = ''' + _/_/_/ _/ _/_/ + _/ _/ _/_/ _/_/_/ _/_/_/ _/ _/ _/ _/ + _/ _/_/ _/ _/ _/ _/_/ _/ _/_/ + _/ _/ _/ _/ _/ _/ _/ _/ _/ + _/_/_/ _/ _/_/_/ _/_/_/ _/ _/ _/_/ _/''' +print(u'\u001b[31m {} \u001b[0m'.format(BANNER)) + + +def set_logger(level): + """ + Simple logging setup + + Arguments + --------- + level: logging.level + Logging level of type logging.level + + Returns + ------- + logger: obj + logging object use for log output + """ + log = logging.getLogger(__name__) + log.setLevel(level) + handler = logging.StreamHandler(sys.stderr) + formatter = logging.Formatter('%(levelname)-8s %(asctime)-8s %(message)s') + handler.setFormatter(formatter) + log.addHandler(handler) + return log + +logger = set_logger(logging.INFO) + + +def parse_args(): + """ + Arg parsing + """ + parser = argparse.ArgumentParser(prog=sys.argv[0], + argument_default=None, usage="%(prog)s ") + mut_group = parser.add_mutually_exclusive_group(required=True) + api_group = parser.add_argument_group('API arguments') + api_group.add_argument_group(mut_group) + hc_group = parser.add_argument_group('Hashcat arguments') + misc_group = parser.add_argument_group('Miscellaneous arguments') + mut_group.add_argument('-l', '--login', default=False, action='store_true', + help='Login and receive token') + mut_group.add_argument('-L', '--logout', default=False, action='store_true', + help='Logout current session') + mut_group.add_argument('-a', '--add', default=False, action='store_true', + help='Add job') + mut_group.add_argument('-d', '--delete', default=False, action='store_true', + help='Delete job and totally remove all trace') + mut_group.add_argument('-s', '--stop', default=False, action='store_true', + help='Stop job and move to complete queue') + mut_group.add_argument('-f', '--failed', default=False, action='store_true', + help='Get failed jobs') + mut_group.add_argument('-r', '--restore', default=False, action='store_true', + help='Resotre selected job, re-adding to queue using' + ' the stored restore point if there is one') + mut_group.add_argument('-j', '--job', default=False, action='store_true', + help='Get job details') + mut_group.add_argument('-p', '--pause', default=False, action='store_true', + help='Pause selected job') + mut_group.add_argument('-c', '--complete', default=False, action='store_true', + help='Get list of completed jobs') + mut_group.add_argument('-m', '--mov', default=False, action='store_true', + help='Move job') + mut_group.add_argument('-q', '--queue', default=False, action='store_true', + help='Retrieve current queue state') + mut_group.add_argument('-o', '--options', default=False, action='store_true', + help='Retrieve available options (wordlists/rules)') + hc_group.add_argument('--job_id', default=None, type=str, + help='Job ID used for queue reference and Hashcat' + 'session') + hc_group.add_argument('--attack_mode', default=None, type=int, + help='Hashcat attack mode, 0=wordlist/rules,' + '2=combinator, 2=, 3=brute force,') + hc_group.add_argument('--hash_mode', default=None, type=str, + help='Hashcat (-m) mode number corresponding to hash ' + 'algorithm') + hc_group.add_argument('--url', default=None, type=str, required=True, + help='URL to use') + hc_group.add_argument('--hash_file', default=None, type=str, + help='File containing list of hashes') + hc_group.add_argument('--name', default=None, type=str, + help='Friendly name for job') + hc_group.add_argument('--wordlist', default=None, type=str, + help='Wordlist name to use for cracking job') + hc_group.add_argument('--rules', default=None, type=str, + help='Rule file name corresponding to list of rules ' + 'stored on the CrackQ server') + hc_group.add_argument('--mask', default=None, type=str, + help='Hashcat mask to use, d=digit, l=lower,' + ' u=upper, a=all, s=symbol, e.g. ?a?a?a?a') + hc_group.add_argument('-u', '--username', default=False, + action='store_true', help='Supply hash in format' + ' including username (admin:deadbeef)') + hc_group.add_argument('--disable_brain', default=False, + action='store_true', help='Manually disable brain') + misc_group.add_argument('--user', default='admin', type=str, + help='Username to use') + misc_group.add_argument('--passwd', default=None, type=str, + help='Password to use, leave this blank' + 'to enter securely') + misc_group.add_argument('--proxy', default=None, type=str, + help='Set custom proxy options') + misc_group.add_argument('--disable_ssl_verify', default=False, + action='store_true', help='Disable SSL certificate' + 'verification. Default is False (which verifies' + 'the cert)') + return parser.parse_args() + + +def main(): + opts = parse_args() + if opts.disable_ssl_verify: + verify = False + else: + verify = True + if opts.rules: + rules = [rule for rule in opts.rules.split(',')] + else: + rules = None + if opts.hash_file: + try: + with open(opts.hash_file, 'r') as hash_fh: + hash_list = [hashl.strip() for hashl in hash_fh] + query_args = { + 'mask': opts.mask, + 'hash_list': hash_list, + 'wordlist': opts.wordlist, + 'attack_mode': opts.attack_mode, + 'hash_mode': opts.hash_mode, + 'rules': rules, + 'name': opts.name, + 'username': opts.username, + 'disable_brain': opts.disable_brain, + 'job_id': opts.job_id, + } + except FileNotFoundError as err: + logger.error('Hash file not found: {}'.format(err)) + exit(1) + except (IOError, TypeError) as err: + logger.error('No hash file provided: {}'.format(err)) + exit(1) + + if opts.proxy: + proxy_dict = {'http': opts.proxy, + 'https': opts.proxy, + } + client = client_rest.ClientReq(opts.url, + verify=verify, + proxy=proxy_dict) + else: + client = client_rest.ClientReq(opts.url, + verify=verify) + if opts.add: + if not all([opts.hash_file, + str(opts.attack_mode), + opts.hash_mode]): + logger.error('Not enough arguments provided') + exit(1) + resp = client.add_job(query_args) + elif opts.login: + query_args = { + 'user': opts.user if opts.user else getpass.getpass( + 'Enter Username:'), + 'password': opts.passwd if opts.passwd else getpass.getpass( + 'Enter Password:'), + } + resp = client.login(query_args) + if resp.status_code == 200: + token = resp.cookies.get_dict() + token_path = str(Path.home() / '.crackq/token.txt') + try: + with open(token_path, 'w') as fh_token: + fh_token.write(json.dumps(token)) + except json.decoder.JSONDecodeError: + logger.error('Auth failed or invalid token returned') + except FileNotFoundError: + logger.debug('No token file found') + Path.mkdir(Path.home() / '.crackq', exist_ok=True) + with open(token_path, 'w') as fh_token: + fh_token.write(json.dumps(token)) + + elif opts.logout: + resp = client.logout() + elif opts.restore: + query_args = { + 'job_id': opts.job_id, + } + resp = client.add_job(query_args) + elif opts.delete: + resp = client.del_job(opts.job_id) + elif opts.stop: + resp = client.stop_job(opts.job_id) + elif opts.mov: + resp = client.mov_job(opts.job_id) + elif opts.complete: + resp = client.q_complete() + elif opts.failed: + resp = client.q_failed() + elif opts.job: + resp = client.job_details(opts.job_id) + elif opts.options: + resp = client.options() + else: + resp = client.q_all() + print('Status: {}'.format(resp.status_code)) + try: + pprint.pprint(resp.json()) + except json.decoder.JSONDecodeError: + pass diff --git a/crackq_client/client_rest.py b/crackq_client/client_rest.py new file mode 100644 index 0000000..9288c7b --- /dev/null +++ b/crackq_client/client_rest.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- + +""" +Python client for interacting with CrackQ REST API. + +A queuing system for multi-user hash cracking using Hashcat. + +Author: dturner@trustwave (@f0cker_) +""" + +import logging +import json +import os +import sys +from pathlib import Path +from requests import Session + +os.umask(0o077) + + +def set_logger(level): + """ + Simple logging setup + + Arguments + --------- + level: logging.level + Logging level of type logging.level + + Returns + ------- + logger: obj + logging object use for log output + """ + logger = logging.getLogger(__name__) + logger.setLevel(level) + handler = logging.StreamHandler(sys.stderr) + formatter = logging.Formatter('%(levelname)-8s %(asctime)-8s %( message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + return logger + + +class ClientReq(): + """ + This class defines all the different requests that can + be made to the API + """ + def __init__(self, url, proxy=None, verify=True): + logger = set_logger(logging.INFO) + self.sess = Session() + self.url = url + self.proxy = proxy + self.verify = verify + self.headers = '' + try: + with open(str(Path.home() / '.crackq/token.txt')) as fh_token: + token = json.loads(fh_token.read()) + for name, value in token.items(): + self.sess.cookies.set(name, value) + except FileNotFoundError: + logger.debug('No token file found') + except json.decoder.JSONDecodeError: + logger.debug('Token read error') + except KeyError: + logger.debug('Token read error') + + def q_all(self): + """View current queue""" + return self.sess.get(self.url + '/api/queuing/all', + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + def job_details(self, job_id): + """Get details for specified job ID""" + return self.sess.get(self.url + '/api/queuing/{:s}'.format(job_id), + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + def options(self): + """View available options (hash modes, wordlists, rules etc)""" + return self.sess.get(self.url + '/api/options', + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + ###***remove/unused?? + def job_pause(self, job_id): + """Pause job""" + return self.sess.get(self.url + '/api/queuing/{:s}'.format(job_id), + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + def q_failed(self): + """View failed queue""" + return self.sess.get(self.url + '/api/queuing/failed', + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + def q_complete(self): + """View complete queue""" + return self.sess.get(self.url + '/api/queuing/complete', + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + def add_job(self, data_dict): + """Add a new job. Provide relevant post data as dictionary""" + return self.sess.post(self.url + '/api/add', + json=data_dict, + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + def del_job(self, job_id): + "Delete specified job ID""" + return self.sess.delete(self.url + '/api/queuing/{:s}'.format(job_id), + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + def stop_job(self, job_id): + """Stop specified job ID""" + return self.sess.patch(self.url + '/api/queuing/{:s}'.format(job_id), + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + ###***Unfinished + def mov_job(self, job_id): + """Move specified job ID to specified place in queue""" + return self.sess.put(self.url + '/api/mov/{:s}/'.format(job_id), + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + def login(self, data_dict): + """Login to API, provide creds via dictionary""" + return self.sess.post(self.url + '/api/login', + json=data_dict, + proxies=self.proxy, + headers=self.headers, + verify=self.verify) + + def logout(self): + """Logout""" + return self.sess.get(self.url + '/api/logout', + proxies=self.proxy, + headers=self.headers, + verify=self.verify) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bbb26c9 --- /dev/null +++ b/setup.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +from setuptools import setup + +with open("README.md", "r") as fh: + long_description = fh.read() + setup( + name='crackq_client', + author='Daniel Turner', + version='0.0.1', + packages=['crackq_client'], + description="RESTful client for CrackQ", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/f0cker/crackq_client", + install_requires=[ + 'idna>=2.8', + 'requests>=2.22.0', + ], + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX", + "Operating System :: MacOS", + ], + entry_points={ + 'console_scripts': [ + 'crackq = crackq_client.client:main', + ] + }, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/kerb18200_test.hash b/tests/kerb18200_test.hash new file mode 100644 index 0000000..53a323b --- /dev/null +++ b/tests/kerb18200_test.hash @@ -0,0 +1 @@ +$krb5asrep$23$user@domain.com:3e156ada591263b8aab0965f5aebd837$007497cb51b6c8116d6407a782ea0e1c5402b17db7afa6b05a6d30ed164a9933c754d720e279c6c573679bd27128fe77e5fea1f72334c1193c8ff0b370fadc6368bf2d49bbfdba4c5dccab95e8c8ebfdc75f438a0797dbfb2f8a1a5f4c423f9bfc1fea483342a11bd56a216f4d5158ccc4b224b52894fadfba3957dfe4b6b8f5f9f9fe422811a314768673e0c924340b8ccb84775ce9defaa3baa0910b676ad0036d13032b0dd94e3b13903cc738a7b6d00b0b3c210d1f972a6c7cae9bd3c959acf7565be528fc179118f28c679f6deeee1456f0781eb8154e18e49cb27b64bf74cd7112a0ebae2102ac diff --git a/tests/test_client.sh b/tests/test_client.sh new file mode 100644 index 0000000..748d572 --- /dev/null +++ b/tests/test_client.sh @@ -0,0 +1,2 @@ +crackq --url http://127.0.0.1:8081 -q +crackq --url http://127.0.0.1:8081 -a --hash_mode 1000 --hash_file deadbeef.hashes --attack_mode 0 --wordlist rockyou --name crackqcli_test