diff --git a/README.md b/README.md index f476888..d70f685 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,67 @@ -# Wilbur # +# UHP.py # -A python utility to match two colon delimited files containing usernames, hashes, and cracked passwords. +## A new and improved Wilbur.py that acutally works ## + +A python utility to match two colon delimited files containing usernames, hashes, and cracked passwords. Will also include different metrics about the cracked passwords. Will also generate a Crackhound file to be used with Crackhound.py. + +## ALL FILES MUST BE WITHIN THE SAME DIRECTORY ## ## Functions ## + +Generate a clean hash list to be uploaded to Hashtopolis. + +`Python3 ./UHP.py ` + +Example: + +`Python3 ./UHP.py NTDS_DUMP.txt` + +This will generate a cleaned.txt file of all parsed user NT hashes that can be uploaded to Hashtopolis for cracking. +This will also generate two CSV files that will be used next. + +- user_hash.csv : CSV file that will have USERNAME:NTHASH. +- hash_password.csv : CSV file that YOU will update with cracked hashes in the format of HASH:ClearTextPassword. + +After updating hash_password.csv with the cracked passwords run the following: + +`Python3 ./UHP.py -d ` + +Example: + +`Python3 ./UHP.py -d CPT.LOCAL` -Matches cracked passwords and the hash with the username from two files that are colon delimited. Run the following command: +This will generate three files. + +- matched.csv : Will have all USERS matched with CLEARTEXT passwords. +- metrics.txt : Will have a varity of different metrics on the cracked passwords. +- crackhound.txt : Ready to be ran with Crackhound.py to update Bloodhound + +Example Output: -`./wilbur.py ` +```python + python3 ../UHP.py NTDS_DUMP.txt + Cleaned hashes saved to 20230325_001256_cleaned.txt, ready for upload to Hashtopolis. +``` -Both input files should have the following **headers**: +``` +cat 20230325_001256_cleaned.txt +23e1d10001876b0078a9a779017fc025 +31d6cfe0d16ae931b73c59d7e0c089c0 +c82d13d85e7f4d04b8614295063c1e28 +23e1d10001876b0078a9a779017fc026 +23e1d10001876b0078a9a779017fc027 +23e1d10001876b0078a9a779017fc028 +23e1d10001876b0078a9a779017fc029 +23e1d10001876b0078a9a779017fc030 +23e1d10001876b0078a9a779017fc031 +23e1d10001876b0078a9a779017fc032 +23e1d10001876b0078a9a779017fc032 +``` -Password File: `hash:password` +```python +python3 ../UHP.py -d CPT.LOCAL +Matched results saved to 20230325_001523_matched.csv +Metrics results saved to 20230325_001523_metrics.txt +Crackhound results saved to 20230325_001523_crackhound.tx +``` -User File: `hash:user` \ No newline at end of file diff --git a/UHP.py b/UHP.py new file mode 100644 index 0000000..43d374a --- /dev/null +++ b/UHP.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +import argparse +import csv +import re +from datetime import datetime +from prettytable import PrettyTable + + +def read_csv_to_dict(filename, delimiter=','): + with open(filename, mode='r', newline='') as csv_file: + reader = csv.DictReader(csv_file, delimiter=delimiter) + return [row for row in reader] + + +def save_dict_to_csv(file_name, data_list, delimiter=','): + with open(file_name, 'w', newline='') as csv_file: + if len(data_list) > 0: + fieldnames = list(data_list[0].keys()) + writer = csv.DictWriter( + csv_file, fieldnames=fieldnames, delimiter=delimiter) + writer.writeheader() # Write headers to the CSV file + for row in data_list: + writer.writerow(row) + + +def parse_ntds_dump(filename): + """Parses the NTDS.dit dump file to extract usernames and NTHashes.""" + user_hash_list = [] + + with open(filename, 'r') as file: + for line in file: + match = re.search( + r'(?:(?P[\w]+)\\)?(?P[\w]+):(?P\d+):[^:]+:(?P[^:]+):::', line) + if match: + user_hash_list.append({ + 'user': match.group('username'), + 'hash': match.group('hash') + }) + + return user_hash_list + + +def clean_empty_password(matches): + """Cleans empty passwords from the matches list.""" + return [match for match in matches if match["password"]] + + +def calculate_password_complexity(password): + complexity = 0 + if any(c.isdigit() for c in password): + complexity += 1 + if any(c.islower() for c in password): + complexity += 1 + if any(c.isupper() for c in password): + complexity += 1 + if any(c in "!@#$%^&*()-=_+[]{}|;':\",./<>?" for c in password): + complexity += 1 + return complexity + + +def generate_metrics(matches): + pt = PrettyTable() + pt.field_names = ["User", "Password", "Password Length", + "Password Complexity", "Reuse Count"] + + # Sort matches by password length + matches.sort(key=lambda x: len(x["password"])) + + reuse_counts = {} + complexity_counts = {i: 0 for i in range(1, 5)} + password_users = {} + + for match in matches: + user = match["user"] + password = match["password"] + password_complexity = calculate_password_complexity(password) + if password not in reuse_counts: + reuse_counts[password] = 0 + password_users[password] = [] + reuse_counts[password] += 1 + complexity_counts[password_complexity] += 1 + password_users[password].append(user) + + for match in matches: + user = match["user"] + password = match["password"] + password_length = len(password) + password_complexity = calculate_password_complexity(password) + reuse_count = reuse_counts[password] + pt.add_row([user, password, password_length, + password_complexity, reuse_count]) + + pt_shared = PrettyTable() + pt_shared.field_names = ["Password", "Users"] + + for password, users in password_users.items(): + if len(users) > 1: + pt_shared.add_row([password, ", ".join(users)]) + + output_filename = datetime.now().strftime("%Y%m%d_%H%M%S") + "_metrics.txt" + with open(output_filename, "w") as f: + f.write(pt.get_string()) + f.write("\n\nPassword Complexity Counts:\n") + for complexity, count in complexity_counts.items(): + f.write(f"Complexity {complexity}: {count}\n") + f.write("\nUsers with Shared Passwords:\n") + f.write(pt_shared.get_string()) + + print(f"Metrics results saved to {output_filename}") + + +def create_hash_password_csv(filename): + """Create a CSV file with headers 'hash' and 'password'.""" + with open(filename, mode='w', newline='') as csv_file: + writer = csv.DictWriter(csv_file, fieldnames=[ + 'hash', 'password'], delimiter=':') + writer.writeheader() + + +def create_csv(filename, fieldnames, delimiter): + with open(filename, mode='w', newline='') as csv_file: + writer = csv.DictWriter( + csv_file, fieldnames=fieldnames, delimiter=delimiter) + writer.writeheader() + + +def save_cleaned_hashes(user_hash_list): + current_time = datetime.now().strftime('%Y%m%d_%H%M%S') + output_filename = f"{current_time}_cleaned.txt" + + with open(output_filename, "w") as f: + for item in user_hash_list: + f.write(item["hash"] + "\n") + print( + f"Cleaned hashes saved to {output_filename}, ready for upload to Hashtopolis.") + + +def save_crackhound_format(matches, domain): + domain = domain.upper() + + current_time = datetime.now().strftime('%Y%m%d_%H%M%S') + output_filename = f"{current_time}_crackhound.txt" + + with open(output_filename, "w") as f: + for match in matches: + user = match["user"].upper() + hash_value = match["hash"] + password = match["password"] + f.write(f"{domain}\\{user}:{hash_value}:{password}\n") + print(f"Crackhound results saved to {output_filename}") + + +def main(): + """Script to parse NTDS.dit Data with cracked hashes from Hashtopolis.""" + parser = argparse.ArgumentParser( + description='''This script assist you in cleaning NTDS.dit and generating needed files. + :param a: Ensure all files are in the SAME DIRECTORY when running this script. + To generate user_hash.csv and hash_password.csv files, use --generate. + + Example usage: + 1. Python3 UHP.py NTDS_DUMP.txt - To generate a clean hash file to upload to Hashtopolis for cracking. + 2. Next, update the hash_password.csv file with the cracked passwords from Hashtopolis. + 3. Then run Python3 UHP.py -d . This will generate three files for you: + - matched.csv: which will include the username and cleartext password. + - metrics.txt: which will include different metrics about password usage. + - crackhound.txt: which will be used by Crackhound to mark users owned and add plaintext password to object in Bloodhound.''', + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + parser.add_argument( + "--generate", + action="store_true", + dest="generate", + default=False, + help="Generate CSV files with headers only.", + ) + + parser.add_argument( + "NTDS_DUMP", + action="store", + nargs='?', # Make this argument optional + default=None, + help="The NTDS.dit dump file.", + ) + + parser.add_argument( + "-d", "--domain", + action="store", + dest="domain", + default=None, + help="Specify the domain name.", + ) + + args = parser.parse_args() + + if args.generate: + create_csv('user_hash.csv', ['user', 'hash'], ':') + create_csv('hash_password.csv', ['hash', 'password'], ':') + elif args.NTDS_DUMP: + ntds_dump = args.NTDS_DUMP + user_hash_list = parse_ntds_dump(ntds_dump) + save_dict_to_csv('user_hash.csv', user_hash_list, ':') + save_cleaned_hashes(user_hash_list) + create_csv('hash_password.csv', ['hash', 'password'], ':') + + else: + user_hash_list = read_csv_to_dict('user_hash.csv', ':') + hash_password_list = read_csv_to_dict('hash_password.csv', ':') + + matches = [ + {"user": user["user"], "hash": password["hash"], + "password": password["password"]} + for user in user_hash_list + for password in hash_password_list + if user["hash"] == password["hash"] + ] + + current_time = datetime.now().strftime('%Y%m%d_%H%M%S') + save_dict_to_csv(f'{current_time}_matched.csv', matches, ':') + print(f"Matched results saved to {current_time}_matched.csv") + generate_metrics(matches) + if args.NTDS_DUMP: + save_crackhound_format(matches, args.NTDS_DUMP) + if args.domain: + save_crackhound_format(matches, args.domain) + + +if __name__ == "__main__": + main() diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 96e02b9..0000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -addopts = -v -ra --cov -testpaths = tests/ \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index cc6b288..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,6 +0,0 @@ -black -coverage -coveralls != 1.11.0 -IPython -pytest -pytest-cov diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e5068d0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +prettytable==3.6.0 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index d567494..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,118 +0,0 @@ -"""pytest plugin configuration. - -https://docs.pytest.org/en/latest/writing_plugins.html#conftest-py-plugins -""" - -import pytest - - -@pytest.fixture -def match_list(): - """Return list of matched username, password, and hashes.""" - return [ - { - "user": "domain1.local\\Bill", - "hash": "d739c6021d574f5f19822feecae9db15", - "password": "p@ssword", - }, - { - "user": "domain1.local\\Jane", - "hash": "4c604a4431bf49c1bdcd3b1f458efdd4", - "password": "doe", - }, - { - "user": "domain1.local\\John", - "hash": "4c604a4431bf49c1bdcd3b1f458efdd4", - "password": "doe", - }, - { - "user": "domain2.local\\Frank", - "hash": "50f57adca07aca56d165aaf2d958e03c", - "password": "YellowFin32!", - }, - { - "user": "domain2.local\\Jill", - "hash": "dc35d01a6d8140dd5bf978ea3ab7c3d2", - "password": "Wash3r", - }, - { - "user": "domain2.local\\Mike", - "hash": "d739c6021d574f5f19822feecae9db15", - "password": "p@ssword", - }, - { - "user": "domain1.local\\John", - "hash": "d739c6021d574f5f19822feecae9db15", - "password": "p@ssword", - }, - { - "user": "domain1.local\\Sam", - "hash": "d739c6021d574f5f19822feecae9db15", - "password": "p@ssword", - }, - { - "user": "domain2.local\\You", - "hash": "c8137e7842466aa292c143a9be887755", - "password": "", - }, - { - "user": "domain1.local\\Charlie", - "hash": "fa19f8748a9b52a1138470b446969633", - "password": "YankyRoad1@", - }, - { - "user": "domain1.local\\admin", - "hash": "21232f297a57a5a743894a0e4a801fc3", - "password": "admin", - }, - ] - - -@pytest.fixture -def example_output_list(): - """Return list of matched username, password, and hashes.""" - return [ - "|Complexity|Count|", - "|--|--|", - "|1|2|", - "|2|1|", - "|3|1|", - "|4|2|", - "
The top 5 passwords:", - "", - "|Count|Password|", - "|--|--|", - "|4|p@ssword|", - "|2|doe|", - "|1|YellowFin32!|", - "|1|Wash3r|", - "|1|YankyRoad1@|", - "
The password lengths:", - "", - "|Length|Count|", - "|--|--|", - "|3|2|", - "|5|1|", - "|6|1|", - "|8|4|", - "|11|1|", - "|12|1|", - ] - - -@pytest.fixture -def example_owned_output(): - """Returns a list of owned user accounts with domain.""" - return [ - "Bill@domain1.local", - "Jane@domain1.local", - "John@domain1.local", - "Frank@domain2.local", - "Jill@domain2.local", - "Mike@domain2.local", - "John@domain1.local", - "Sam@domain1.local", - "You@domain2.local", - "Charlie@domain1.local", - "admin@domain1.local", - ] diff --git a/tests/test_wilbur.py b/tests/test_wilbur.py deleted file mode 100644 index c0e8ad6..0000000 --- a/tests/test_wilbur.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env pytest -vs -"""Tests for Wilbur.""" - -# some_file.py -import sys - -# insert at 1, 0 is the script path (or '' in REPL) -sys.path.append(".") - -from wilbur import ( - clean_empty_password, - get_password_length, - output_metrics, - get_password_complexity, - get_password_reuse, - get_username_password_match, - output_owned, -) - - -class TestWilbur: - """Test the methods of Wilbur""" - - def test_clean_empty_password(self, match_list): - """Test the clean empty password.""" - match_list.append( - { - "user": "domain2.local/You2", - "hash": "c8137e7842466aa292c143a9be887755", - "password": "", - } - ) - assert len(clean_empty_password(match_list)) == 10 - - def test_get_password_length(self, match_list): - """Test the get password length method.""" - assert get_password_length(match_list) == {8: 4, 3: 2, 12: 1, 6: 1, 11: 1, 5: 1} - - def test_output_metrics(self, example_output_list, match_list): - """Test the output metrics method.""" - assert output_metrics(match_list, 5) == example_output_list - - def test_get_password_reuse(self, match_list): - """Test the password reuse method.""" - assert get_password_reuse(match_list, 5) == [ - ("p@ssword", 4), - ("doe", 2), - ("YellowFin32!", 1), - ("Wash3r", 1), - ("YankyRoad1@", 1), - ] - - def test_get_password_complexity(self, match_list): - """Test the password complexity method.""" - assert get_password_complexity(match_list) == {1: 2, 2: 1, 3: 1, 4: 2} - - def test_get_username_password_match(self, match_list): - """Test the username password match method.""" - assert get_username_password_match(match_list) == [ - { - "user": "domain1.local\\admin", - "hash": "21232f297a57a5a743894a0e4a801fc3", - "password": "admin", - }, - ] - - def test_output_owned(self, example_owned_output, match_list): - """Test the output owned method.""" - assert output_owned(match_list) == example_owned_output diff --git a/wilbur.py b/wilbur.py deleted file mode 100755 index b307495..0000000 --- a/wilbur.py +++ /dev/null @@ -1,273 +0,0 @@ -#!/usr/bin/env python3 - -# Standard Python Libraries -import argparse -import collections -import csv -from itertools import islice - -SPECIAL_CHARACTER = """!@#$%^&*()-+?_=,<>/""" - - -def take(n, iterable): - "Return first n items of the iterable as a list" - return list(islice(iterable, n)) - - -def load_csv_to_dict(filename): - """Loads csv to list of dicts.""" - - _list = list() - with open(filename, mode="r", encoding="utf-8") as csv_file: - csv_reader = csv.DictReader(csv_file, delimiter=":") - _list = list() - for row in csv_reader: - _list.append(row) - - return _list - - -def save_dict_to_csv(filename, _list): - """Save list of dicts to csv""" - - with open(filename, mode="w") as csv_file: - fieldnames = ["user", "hash", "password"] - writer = csv.DictWriter(csv_file, fieldnames=fieldnames) - - writer.writeheader() - for row in _list: - writer.writerow(row) - - -def clean_empty_password(matchs): - """Cleans empty passwords from the matchs list.""" - clean_match = list() - for match in matchs: - if match["password"] and match["password"] != "": - clean_match.append(match) - - return clean_match - - -def get_password_complexity(matchs): - """Return a list of tuples for the complexity of each password.""" - passwords = dict() - for match in clean_empty_password(matchs): - passwords[match["password"]] = 0 - if any(char.islower() for char in match["password"]): - passwords[match["password"]] += 1 - if any(char.isupper() for char in match["password"]): - passwords[match["password"]] += 1 - if any(char.isdigit() for char in match["password"]): - passwords[match["password"]] += 1 - if any(char in SPECIAL_CHARACTER for char in match["password"]): - passwords[match["password"]] += 1 - - complexity = {1: 0, 2: 0, 3: 0, 4: 0} - for count in passwords.values(): - complexity[count] += 1 - - return complexity - - -def get_password_length(matchs): - """Return a dict of password length and the count of that length.""" - length_count = dict() - for match in clean_empty_password(matchs): - password_length = len(match["password"]) - if password_length in length_count.keys(): - length_count[password_length] += 1 - else: - length_count[password_length] = 1 - - # Orders list based on - length_count = collections.OrderedDict(sorted(length_count.items())) - - return length_count - - -def get_password_reuse(matchs, num): - """Returns a list of tuples for the num highest password reuses.""" - passwords = dict() - for match in clean_empty_password(matchs): - if f'{match["password"]}' in passwords.keys(): - passwords[f'{match["password"]}'] += 1 - else: - passwords[f'{match["password"]}'] = 1 - - return take( - num, - dict(sorted(passwords.items(), key=lambda item: item[1], reverse=True)).items(), - ) - - -def get_username_password_match(matchs): - """Return a count of instances where username and password match.""" - - user_pass_match_list = list() - - for match in matchs: - if "\\" in match["user"]: - username = match["user"].split("\\")[1] - if username == match["password"]: - user_pass_match_list.append(match) - - return user_pass_match_list - - -def output_metrics(matchs, num): - """Out puts the metrics to a markdown file.""" - - output_list = [] - - # Builds the Complexity count table. - output_list.append("|Complexity|Count|") - output_list.append("|--|--|") - - complexities = get_password_complexity(matchs) - for complexity, count in complexities.items(): - output_list.append(f"|{complexity}|{count}|") - - # Build password reuse list. - get_password_reuse(matchs, num) - - output_list.append(f"
The top {num} passwords:") - output_list.append("") - output_list.append("|Count|Password|") - output_list.append("|--|--|") - reused_passwords = get_password_reuse(matchs, num) - for password in reused_passwords: - - output_list.append(f"|{password[1]}|{password[0]}|") - - output_list.append("
The password lengths:") - output_list.append("") - output_list.append("|Length|Count|") - output_list.append("|--|--|") - - password_length_dict = get_password_length(matchs) - for length, count in password_length_dict.items(): - output_list.append(f"|{length}|{count}|") - - return output_list - - -def output_owned(matchs): - """Retruns a list of owned usernames with domains. - - Args: - matchs (_type_): _description_ - - Returns: - list(strings): Returns a list of usernames with domains - formated as username@domain. - """ - - owned_users = list() - - for match in matchs: - split_user = match["user"].split("\\") - owned_users.append(f"{split_user[1]}@{split_user[0]}") - - return owned_users - - -def main(): - """Merge a list of user name with hashes and a list of password with hashes.""" - """Set up logging, connect to Postgres, call requested function(s).""" - parser = argparse.ArgumentParser( - description="Merge two files which contain usernames with hashes and passwords with hashes.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - - parser.add_argument( - "--no-output", - action="store_true", - dest="no_output", - default=False, - help="Prevent the output of matches.cvs.", - ) - - parser.add_argument( - "-s", - "--same", - dest="same", - action="store_true", - default=True, - help="Saves a list matching username and passwords.", - ) - - parser.add_argument( - "-r", - "--reuse", - action="store", - dest="reuse", - default=10, - type=int, - help="Return the top number of password's that get reused.", - ) - - parser.add_argument( - "PASSWORD_FILE", - action="store", - help="The file holding password and hash.", - ) - parser.add_argument( - "USER_FILE", - action="store", - help="The file holding username and hash.", - ) - - args = parser.parse_args() - - # Load files into lists. - passwords = load_csv_to_dict(args.PASSWORD_FILE) - users = load_csv_to_dict(args.USER_FILE) - - matchs = list() - - for user in users: - for password in passwords: - if user["hash"] == password["hash"]: - matchs.append( - { - "user": user["user"], - "hash": user["hash"], - "password": password["password"], - } - ) - - if not args.no_output: - save_dict_to_csv("matched.csv", matchs) - - print() - print('matchs saved to "matched.csv"') - print() - - if args.same: - save_dict_to_csv("same.csv", get_username_password_match(matchs)) - - print('Matching username nad passwords saved to "same.txt"') - - print('The metrics saved to "metrics.md"') - - metrics_output_list = output_metrics(matchs, args.reuse) - with open("metrics.md", "w") as fp: - for line in metrics_output_list: - fp.write(f"{line}") - - print('Owned users saved to "owned.txt"') - - owned_output_list = output_owned(matchs) - with open("owned.txt", "w") as fp: - for line in owned_output_list: - fp.write(f"{line}") - - print() - print("Some people know things about the universe that nobody") - print("ought to know, and can do things that nobody ought to") - print("be able to do. -H.P. Lovecraft") - - -if __name__ == "__main__": - main()