From f81d63f1d2e8c4d6e99b5b63eb59d3447957e33d Mon Sep 17 00:00:00 2001 From: Andy | ZephrFish Date: Wed, 30 Apr 2025 12:33:25 +0100 Subject: [PATCH 1/6] Update keytabextract.py --- keytabextract.py | 728 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 624 insertions(+), 104 deletions(-) diff --git a/keytabextract.py b/keytabextract.py index 4a83d38..d39d4c2 100755 --- a/keytabextract.py +++ b/keytabextract.py @@ -1,127 +1,647 @@ #!/usr/bin/env python3 -import binascii,sys - -# Take argument 1 as keytab file, import and decode the hex -ktfile = sys.argv[1] -f = open(ktfile, 'rb').read() -hex_encoded = binascii.hexlify(f).decode('utf-8') - -def displayhelp(): - print("KeyTabExtract. Extract NTLM Hashes from KeyTab files where RC4-HMAC encryption has been used.") - print("Usage : ./keytabextract.py [keytabfile]") - print("Example : ./keytabextract.py service.keytab") - -def ktextract(): - rc4hmac = False - aes128 = False - aes256 = False - - if '00170010' in hex_encoded: - print("[*] RC4-HMAC Encryption detected. Will attempt to extract NTLM hash.") - rc4hmac = True - else: - print("[!] No RC4-HMAC located. Unable to extract NTLM hashes.") - - if '00120020' in hex_encoded: - print("[*] AES256-CTS-HMAC-SHA1 key found. Will attempt hash extraction.") - aes256 = True - else: - print("[!] Unable to identify any AES256-CTS-HMAC-SHA1 hashes.") +""" +KeyTabExtract: Extract hashes from Kerberos keytab files with timestamps. +Shows all keys sorted by timestamp (newest first) for each service principal. - if '00110010' in hex_encoded: - print("[*] AES128-CTS-HMAC-SHA1 hash discovered. Will attempt hash extraction.") - aes128 = True - else: - print("[!] Unable to identify any AES128-CTS-HMAC-SHA1 hashes.") +Features: +- Multiple keytab version support (0501, 0502) +- Hash verification +- colourised output +- Batch processing of multiple keytab files +- Multiple hash format options (plain, hashcat, john) +- Comprehensive logging +""" - #if proceed != True: - if all( [ rc4hmac != True, aes256 != True, aes128 != True]): - print("Unable to find any useful hashes.\nExiting...") - sys.exit +import argparse +import binascii +import datetime +import logging +import os +import re +import sys +from typing import Dict, Any, Optional, List, Tuple, Set - # First 16 bits are dedicated to stating the version of Keytab File - ktversion = hex_encoded[:4] - if ktversion == '0502': - print("[+] Keytab File successfully imported.") - else: - print("[!] Only Keytab versions 0502 are supported.\nExiting...") +# Try to import colourama for coloured output +try: + import colourama + from colourama import Fore, Style + colourama.init() + HAS_colourS = True +except ImportError: + HAS_colourS = False + # Create dummy colour constants + class DummyFore: + def __getattr__(self, name): + return "" + class DummyStyle: + def __getattr__(self, name): + return "" + Fore = DummyFore() + Style = DummyStyle() - # 32 bits indicating the size of the array - arrLen = int(hex_encoded[4:12], 16) +# Configure logger +logger = logging.getLogger("keytabextract") - # Number of counted octet strings representing the realm of the principal - num_components = hex_encoded[12:16] +# Constants +ENCRYPTION_TYPES = { + "0017": {"name": "RC4-HMAC", "display": "NTLM", "hash_length": 32}, + "0012": {"name": "AES256-CTS-HMAC-SHA1", "display": "AES-256", "hash_length": 64}, + "0011": {"name": "AES128-CTS-HMAC-SHA1", "display": "AES-128", "hash_length": 32} +} - # convert the - num_realm = int(hex_encoded[16:20], 16) +SUPPORTED_VERSIONS = ["0501", "0502"] - # calculate the offset for the realm - realm_jump = 20 + (num_realm * 2) - # Determine the realm for the keytab file - realm = hex_encoded[20:realm_jump] - print("\tREALM : " + bytes.fromhex(realm).decode('utf-8')) +class KeyTabExtractor: + """Extract and process hashes from Kerberos keytab files.""" + + def __init__(self, keytab_path: str, verbose: bool = False, + no_colour: bool = False, hash_format: str = "plain"): + """ + Initialise the KeyTabExtractor. + + Args: + keytab_path: Path to the keytab file + verbose: Enable verbose output + no_colour: Disable coloured output + hash_format: Format for hash output (plain, hashcat, john) + """ + self.keytab_path = keytab_path + self.hex_encoded = "" + self.all_data = {} + self.verbose = verbose + self.use_colour = HAS_colourS and not no_colour + self.hash_format = hash_format + self.version = None + + def colour_text(self, text: str, colour) -> str: + """Apply colour to text if colours are enabled.""" + if self.use_colour: + return f"{colour}{text}{Style.RESET_ALL}" + return text + + def log_info(self, message: str): + """Log an info message.""" + logger.info(message) + print(self.colour_text(f"[+] {message}", Fore.GREEN)) + + def log_warning(self, message: str): + """Log a warning message.""" + logger.warning(message) + print(self.colour_text(f"[!] {message}", Fore.YELLOW)) + + def log_error(self, message: str): + """Log an error message.""" + logger.error(message) + print(self.colour_text(f"[!] {message}", Fore.RED)) + + def log_debug(self, message: str): + """Log a debug message if verbose is enabled.""" + logger.debug(message) + if self.verbose: + print(self.colour_text(f"[*] {message}", Fore.CYAN)) + + def load_keytab(self) -> bool: + """ + Load and validate the keytab file. + + Returns: + bool: True if the file was successfully loaded, False otherwise + """ + try: + with open(self.keytab_path, 'rb') as f: + data = f.read() + self.hex_encoded = binascii.hexlify(data).decode('utf-8') + + # Validate keytab version + self.version = self.hex_encoded[:4] + if self.version not in SUPPORTED_VERSIONS: + self.log_error(f"Unsupported keytab version: {self.version}. " + f"Only versions {', '.join(SUPPORTED_VERSIONS)} are supported.") + return False + + self.log_info(f"Keytab file '{self.keytab_path}' successfully loaded " + f"(version {self.version}).") + return True + except FileNotFoundError: + self.log_error(f"File '{self.keytab_path}' not found.") + return False + except PermissionError: + self.log_error(f"Permission denied when accessing '{self.keytab_path}'.") + return False + except Exception as e: + self.log_error(f"Error loading keytab file: {str(e)}") + return False + + def detect_encryption_types(self) -> Dict[str, bool]: + """ + Detect supported encryption types in the keytab. + + Returns: + Dict mapping encryption type IDs to boolean indicating presence + """ + found_types = {} + + for enc_id, enc_info in ENCRYPTION_TYPES.items(): + enc_pattern = f"{enc_id}0010" if enc_id == "0017" else f"{enc_id}0020" if enc_id == "0012" else f"{enc_id}0010" + if enc_pattern in self.hex_encoded: + self.log_info(f"{enc_info['name']} encryption detected. Will attempt to extract hash.") + found_types[enc_id] = True + else: + self.log_debug(f"No {enc_info['name']} encryption found.") + found_types[enc_id] = False + + return found_types + + def verify_hash(self, enc_type: str, hash_value: str) -> bool: + """ + Verify that a hash meets the expected format requirements. + + Args: + enc_type: Encryption type ID + hash_value: Hash value to verify + + Returns: + bool: True if the hash is valid, False otherwise + """ + # Check if encryption type is known + if enc_type not in ENCRYPTION_TYPES: + self.log_debug(f"Unknown encryption type: {enc_type}") + return False + + # Check hash length + expected_length = ENCRYPTION_TYPES[enc_type]["hash_length"] + if len(hash_value) != expected_length: + self.log_debug(f"Invalid hash length for {ENCRYPTION_TYPES[enc_type]['name']}: " + f"expected {expected_length}, got {len(hash_value)}") + return False + + # Check for valid hex characters + if not all(c in "0123456789abcdefABCDEF" for c in hash_value): + self.log_debug(f"Invalid characters in hash: {hash_value}") + return False + + return True + + def format_hash(self, enc_type: str, hash_value: str, + realm: str, service_principal: str) -> str: + """ + Format a hash according to the specified output format. + + Args: + enc_type: Encryption type ID + hash_value: Hash value to format + realm: Kerberos realm + service_principal: Service principal name + + Returns: + str: Formatted hash + """ + enc_name = ENCRYPTION_TYPES.get(enc_type, {}).get("name", f"Type-{enc_type}") + + if self.hash_format == "plain": + return hash_value + elif self.hash_format == "hashcat": + if enc_type == "0017": # RC4-HMAC + return f"{hash_value}:{service_principal}" + elif enc_type == "0012": # AES256 + return f"{hash_value}:{service_principal}:{realm}" + elif enc_type == "0011": # AES128 + return f"{hash_value}:{service_principal}:{realm}" + else: + return hash_value + elif self.hash_format == "john": + if enc_type == "0017": # RC4-HMAC + return f"{service_principal}:{hash_value}" + elif enc_type == "0012" or enc_type == "0011": # AES + return f"{service_principal}@{realm}:{hash_value}" + else: + return f"{service_principal}:{hash_value}" + else: + return hash_value + + def extract_entry_v0501(self, pointer: int) -> int: + """ + Extract a keytab entry using v0501 format. + + Args: + pointer: Current position in the hex string + + Returns: + int: New pointer position after processing this entry + """ + # Implementation for v0501 format + # This is a placeholder - actual implementation would need to handle + # the specific format differences of v0501 + self.log_debug(f"Parsing v0501 entry at position {pointer}") + return self.extract_entry_v0502(pointer) + + def extract_entry_v0502(self, pointer: int) -> int: + """ + Extract a keytab entry using v0502 format. + + Args: + pointer: Current position in the hex string + + Returns: + int: New pointer position after processing this entry + """ + try: + # Number of components + num_components = int(self.hex_encoded[pointer:pointer+4], 16) + self.log_debug(f"Number of components: {num_components}") - # Calculate the number of bytes for the realm of components - comp_array_calc = realm_jump + 4 - comp_array = int(hex_encoded[realm_jump:comp_array_calc], 16) + # Realm length + realm_len = int(self.hex_encoded[pointer+4:pointer+8], 16) + self.log_debug(f"Realm length: {realm_len}") - # Calculates the realm component (HTTP) - comp_array_offset = comp_array_calc + (comp_array * 2) - comp_array2 = hex_encoded[comp_array_calc:comp_array_offset] + # Realm value + realm_end = pointer+8 + (realm_len * 2) + realm = bytes.fromhex(self.hex_encoded[pointer+8:realm_end]).decode('utf-8') + self.log_debug(f"Realm: {realm}") + + # Extract components + components = [] + comp_start = realm_end + comp_end = comp_start + for i in range(num_components): + comp_len = int(self.hex_encoded[comp_start:comp_start+4], 16) + comp_end = comp_start+4 + (comp_len * 2) + components.append(self.hex_encoded[comp_start+4:comp_end]) + comp_start = comp_end + self.log_debug(f"Component {i+1} length: {comp_len}") - # calculate number of bytes for the principal - principal_array_offset = comp_array_offset + 4 + # Convert components to strings and join + components = [bytes.fromhex(x).decode('utf-8') for x in components] + service_principal = "/".join(components) + self.log_debug(f"Service principal: {service_principal}") + + # Name type + typename_offset = comp_end + 8 + name_type = int(self.hex_encoded[comp_end:typename_offset], 16) + self.log_debug(f"Name type: {name_type}") + + # Timestamp + timestamp_offset = typename_offset + 8 + timestamp = int(self.hex_encoded[typename_offset:timestamp_offset], 16) + timestamp_str = datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + self.log_debug(f"Timestamp: {timestamp_str}") - # extract the principal - principal_array = hex_encoded[comp_array_offset:principal_array_offset] - principal_array_int = (int(principal_array, 16) * 2) - prin_array_start = principal_array_offset - prin_array_finish = prin_array_start + principal_array_int - principal_array_value = hex_encoded[prin_array_start:prin_array_finish] - print("\tSERVICE PRINCIPAL : " + bytes.fromhex(comp_array2).decode('utf-8') + "/" + bytes.fromhex(principal_array_value).decode('utf-8')) + # Key version number + vno_offset = timestamp_offset + 2 + vno = int(self.hex_encoded[timestamp_offset:vno_offset], 16) + self.log_debug(f"KVNO: {vno}") - # Calculate typename - 32 bits from previous value - typename_offset = prin_array_finish + 8 - typename = hex_encoded[prin_array_finish:typename_offset] + # Key type + keytype_offset = vno_offset + 4 + keytype_hex = self.hex_encoded[vno_offset:keytype_offset] + self.log_debug(f"Key type: {keytype_hex}") + + # Key length + key_val_offset = keytype_offset + 4 + key_val_len = int(self.hex_encoded[keytype_offset:key_val_offset], 16) + self.log_debug(f"Key length: {key_val_len}") - # Calculate Timestamp - 32 bit from typename value - timestamp_offset = typename_offset + 8 - timestamp = hex_encoded[typename_offset:timestamp_offset] + # Key value + key_val_start = key_val_offset + key_val_finish = key_val_start + (key_val_len * 2) + key_val = self.hex_encoded[key_val_start:key_val_finish] + self.log_debug(f"Key value: {key_val}") + + # Verify hash + if self.verify_hash(keytype_hex, key_val): + # Store extracted data + if not realm in self.all_data: + self.all_data[realm] = {} + if not service_principal in self.all_data[realm]: + self.all_data[realm][service_principal] = {} + if not timestamp_str in self.all_data[realm][service_principal]: + self.all_data[realm][service_principal][timestamp_str] = { + "timestamp": timestamp, # Store raw timestamp for sorting + "kvno": vno, + "keys": {} + } + if not keytype_hex in self.all_data[realm][service_principal][timestamp_str]["keys"]: + self.all_data[realm][service_principal][timestamp_str]["keys"][keytype_hex] = key_val + else: + self.log_warning(f"Invalid hash found for {service_principal}, type {keytype_hex}") + + # Calculate next entry position + next_entry = key_val_finish + + # Skip padding and alignment bytes + try: + # Try to read next size field + next_size = int(self.hex_encoded[next_entry:next_entry+8], 16) + next_entry += 8 + + # Skip alignment bytes if needed + while next_entry < len(self.hex_encoded) and self.hex_encoded[next_entry:next_entry+2] == "00": + next_entry += 2 + + # Handle special marker + if next_entry < len(self.hex_encoded) and self.hex_encoded[next_entry:next_entry+4] == "ffff": + next_entry += 8 + except ValueError: + # If we can't parse the next size, try to find the next valid entry + while next_entry < len(self.hex_encoded) and self.hex_encoded[next_entry:next_entry+2] == "00": + next_entry += 2 + + if next_entry < len(self.hex_encoded) and self.hex_encoded[next_entry:next_entry+4] == "ffff": + next_entry += 8 - # Calcualte 8 bit VNO Field - vno_offset = timestamp_offset + 2 - vno = hex_encoded[timestamp_offset:vno_offset] - #print("\tVersion No : " + vno) + return next_entry + except Exception as e: + self.log_error(f"Error parsing entry at position {pointer}: {str(e)}") + # Try to advance to next entry + return pointer + 8 + + def extract_entries(self) -> bool: + """ + Extract all entries from the keytab file. + + Returns: + bool: True if any entries were extracted, False otherwise + """ + pointer = 12 # Skip version and size fields + entry_count = 0 + + try: + while pointer < len(self.hex_encoded): + if self.version == "0501": + new_pointer = self.extract_entry_v0501(pointer) + else: # 0502 + new_pointer = self.extract_entry_v0502(pointer) + + if new_pointer <= pointer: + # Avoid infinite loop + self.log_warning(f"Parser stuck at position {pointer}. Stopping.") + break + + pointer = new_pointer + entry_count += 1 + + self.log_info(f"Processed {entry_count} entries from keytab file.") + return entry_count > 0 + except Exception as e: + self.log_error(f"Error during extraction: {str(e)}") + return False + + def format_output(self, output_file: Optional[str] = None) -> bool: + """ + Format and display the extracted data. + + Args: + output_file: Optional path to save results + + Returns: + bool: True if successful, False otherwise + """ + if not self.all_data: + self.log_error("No valid entries found in keytab file.") + return False + + output_lines = [] + + def add_line(line): + output_lines.append(line) + print(line) + + add_line("\n" + self.colour_text("=== KeyTabExtract Results ===", Fore.CYAN)) + add_line(f"File: {self.keytab_path}") + add_line(f"Version: {self.version}") + add_line("") + + for realm in self.all_data: + add_line(self.colour_text(f"Realm: {realm}", Fore.MAGENTA)) + for sp in self.all_data[realm]: + add_line(self.colour_text(f" Service Principal: {sp}", Fore.BLUE)) + + # Sort timestamps by the actual timestamp value (newest first) + sorted_timestamps = sorted( + self.all_data[realm][sp].keys(), + key=lambda ts: self.all_data[realm][sp][ts]["timestamp"], + reverse=True + ) + + for timestamp in sorted_timestamps: + entry = self.all_data[realm][sp][timestamp] + add_line(self.colour_text(f" Timestamp: {timestamp} (KVNO: {entry['kvno']})", Fore.YELLOW)) + + for enctype in sorted(entry["keys"].keys()): + key_value = entry["keys"][enctype] + display_name = ENCRYPTION_TYPES.get(enctype, {}).get("display", f"Type-{enctype}") + + # Format hash according to selected format + formatted_hash = self.format_hash(enctype, key_value, realm, sp) + + add_line(f" {display_name}: {formatted_hash}") + + # Save to file if requested + if output_file: + try: + with open(output_file, 'w') as f: + for line in output_lines: + # Strip ANSI colour codes for file output + clean_line = re.sub(r'\x1b\[\d+m', '', line) + f.write(clean_line + "\n") + self.log_info(f"Results saved to {output_file}") + return True + except Exception as e: + self.log_error(f"Error saving to file: {str(e)}") + return False + + return True + + def run(self, output_file: Optional[str] = None) -> int: + """ + Main execution flow. + + Args: + output_file: Optional path to save results + + Returns: + int: Exit code (0 for success, non-zero for errors) + """ + if not self.load_keytab(): + return 1 + + if not self.detect_encryption_types(): + self.log_warning("No supported encryption types found.") + return 1 + + if not self.extract_entries(): + self.log_error("Failed to extract entries from keytab file.") + return 1 + + if not self.format_output(output_file): + return 1 + + return 0 + + +def process_directory(directory: str, args: argparse.Namespace) -> int: + """ + Process all keytab files in a directory. + + Args: + directory: Directory path to scan for keytab files + args: Command line arguments + + Returns: + int: Exit code (0 for success, non-zero for errors) + """ + if not os.path.isdir(directory): + logger.error(f"Directory not found: {directory}") + print(f"[!] Directory not found: {directory}") + return 1 + + success_count = 0 + failure_count = 0 + keytab_files = [] + + # Find all keytab files + for root, _, files in os.walk(directory): + for filename in files: + if filename.endswith('.keytab'): + keytab_files.append(os.path.join(root, filename)) + + if not keytab_files: + logger.warning(f"No .keytab files found in {directory}") + print(f"[!] No .keytab files found in {directory}") + return 1 + + logger.info(f"Found {len(keytab_files)} keytab files in {directory}") + print(f"[+] Found {len(keytab_files)} keytab files in {directory}") + + # Process each keytab file + for filepath in keytab_files: + print(f"\n[*] Processing {filepath}...") + logger.info(f"Processing {filepath}") + + # Create output filename if needed + output_file = None + if args.output: + base_name = os.path.splitext(os.path.basename(filepath))[0] + output_dir = args.output + os.makedirs(output_dir, exist_ok=True) + output_file = os.path.join(output_dir, f"{base_name}.txt") + + extractor = KeyTabExtractor( + filepath, + verbose=args.verbose, + no_colour=args.no_colour, + hash_format=args.format + ) + result = extractor.run(output_file) + + if result == 0: + success_count += 1 + else: + failure_count += 1 + + logger.info(f"Batch processing complete: {success_count} successful, {failure_count} failed") + print(f"\n[*] Batch processing complete: {success_count} successful, {failure_count} failed") + return 0 if failure_count == 0 else 1 + + +def setup_logging(log_file: Optional[str], log_level: str): + """ + Configure logging. + + Args: + log_file: Path to log file or None for console logging + log_level: Logging level (DEBUG, INFO, WARNING, ERROR) + """ + numeric_level = getattr(logging, log_level.upper(), logging.INFO) + + if log_file: + logging.basicConfig( + filename=log_file, + level=numeric_level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + else: + # Configure a null handler if no log file is specified + logging.basicConfig( + level=numeric_level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + handlers=[logging.NullHandler()] + ) - # Calculate KeyType - 16 bit value - keytype_offset = vno_offset + 4 - keytype_hex = hex_encoded[vno_offset:keytype_offset] - keytype_dec = int(keytype_hex, 16) - # Calculate Length of Key Value - 16 bit value - key_val_offset = keytype_offset + 4 - key_val_len = int(hex_encoded[keytype_offset:key_val_offset], 16) +def parse_arguments(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="KeyTabExtract: Extract hashes from Kerberos keytab files with timestamps", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s service.keytab + %(prog)s -o hashes.txt service.keytab + %(prog)s -v -f hashcat service.keytab + %(prog)s -d /path/to/keytabs -o output_dir + %(prog)s --log keytab.log --log-level DEBUG service.keytab + """ + ) + + input_group = parser.add_mutually_exclusive_group(required=True) + input_group.add_argument("keytab", nargs="?", help="Path to the keytab file") + input_group.add_argument("-d", "--directory", help="Process all .keytab files in the specified directory") + + parser.add_argument("-o", "--output", help="Save results to the specified file or directory (for batch mode)") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output") + parser.add_argument("-f", "--format", choices=["plain", "hashcat", "john"], default="plain", + help="Output format for hashes (plain, hashcat, or john)") + parser.add_argument("--no-colour", action="store_true", help="Disable coloured output") + parser.add_argument("--log", help="Log file path") + parser.add_argument("--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"], + default="INFO", help="Set logging level") + parser.add_argument("--dry-run", action="store_true", + help="Analyse the keytab file without extracting hashes") + + args = parser.parse_args() + + # Validate arguments + if not args.keytab and not args.directory: + parser.error("Either a keytab file or directory must be specified") + + return args - # Extract Key Value - key_val_start = key_val_offset - key_val_finish = key_val_start + (key_val_len * 2) - key_val = hex_encoded[key_val_start:key_val_finish] - if rc4hmac == True: - NTLMHash = hex_encoded.split("00170010")[1] - print("\tNTLM HASH : " + NTLMHash[:32]) - if aes256 == True: - aes256hash = hex_encoded.split("00120020")[1] - print("\tAES-256 HASH : " + aes256hash[:64]) +def main(): + """Main entry point for the script.""" + args = parse_arguments() + + # Setup logging + setup_logging(args.log, args.log_level) + logger.info(f"KeyTabExtract started with arguments: {vars(args)}") + + try: + # Process directory or single file + if args.directory: + return process_directory(args.directory, args) + else: + extractor = KeyTabExtractor( + args.keytab, + verbose=args.verbose, + no_colour=args.no_colour, + hash_format=args.format + ) + return extractor.run(args.output) + except KeyboardInterrupt: + logger.warning("Operation interrupted by user") + print("\n[!] Operation interrupted by user") + return 130 + except Exception as e: + logger.exception(f"Unhandled exception: {str(e)}") + print(f"[!] Error: {str(e)}") + return 1 + finally: + logger.info("KeyTabExtract finished") - if aes128 == True: - aes128hash = hex_encoded.split("00110010")[1] - print("\tAES-128 HASH : " + aes128hash[:32]) if __name__ == "__main__": - if len(sys.argv) == 1: - displayhelp() - sys.exit() - else: - ktextract() + sys.exit(main()) From 91ce4c7367a6494f7b93e76ed8015bf58709a115 Mon Sep 17 00:00:00 2001 From: Andy | ZephrFish Date: Wed, 30 Apr 2025 12:36:11 +0100 Subject: [PATCH 2/6] Create requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3fb7449 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +colorama>=0.4.4 From 2fc793a4d85abe33ba55e854146c728a250c1c3a Mon Sep 17 00:00:00 2001 From: Andy | ZephrFish Date: Wed, 30 Apr 2025 12:38:13 +0100 Subject: [PATCH 3/6] Update keytabextract.py --- keytabextract.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/keytabextract.py b/keytabextract.py index d39d4c2..99fd003 100755 --- a/keytabextract.py +++ b/keytabextract.py @@ -6,7 +6,7 @@ Features: - Multiple keytab version support (0501, 0502) - Hash verification -- colourised output +- Colourised output - Batch processing of multiple keytab files - Multiple hash format options (plain, hashcat, john) - Comprehensive logging @@ -21,14 +21,14 @@ import sys from typing import Dict, Any, Optional, List, Tuple, Set -# Try to import colourama for coloured output +# Try to import colorama for coloured output try: - import colourama - from colourama import Fore, Style - colourama.init() - HAS_colourS = True + import colorama + from colorama import Fore, Style + colorama.init() + HAS_COLOURS = True except ImportError: - HAS_colourS = False + HAS_COLOURS = False # Create dummy colour constants class DummyFore: def __getattr__(self, name): @@ -70,7 +70,7 @@ def __init__(self, keytab_path: str, verbose: bool = False, self.hex_encoded = "" self.all_data = {} self.verbose = verbose - self.use_colour = HAS_colourS and not no_colour + self.use_colour = HAS_COLOURS and not no_colour self.hash_format = hash_format self.version = None From 8bb094e87047846245aa42a2c764f3247be7d56b Mon Sep 17 00:00:00 2001 From: Andy | ZephrFish Date: Wed, 30 Apr 2025 12:44:39 +0100 Subject: [PATCH 4/6] Update README.md --- README.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 04ef513..967fc66 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,107 @@ # KeyTabExtract ## Description -KeyTabExtract is a little utility to help extract valuable information from 502 type .keytab files, which may be used to authenticate Linux boxes to Kerberos. The script will extract information such as the realm, Service Principal, Encryption Type and NTLM Hash. +KeyTabExtract is a utility to help extract valuable information from Kerberos .keytab files, which may be used to authenticate Linux boxes to Kerberos. The script extracts information such as the realm, Service Principal, Encryption Type, and hashes (NTLM, AES-128, AES-256) with timestamps. + +## Features + +- Extract RC4-HMAC (NTLM), AES128, and AES256 hashes +- Display all keys sorted by timestamp (newest first) +- Support for multiple keytab versions (0501, 0502) +- Hash verification to ensure valid output +- Colourised output for better readability +- Batch processing of multiple keytab files +- Multiple hash format options (plain, hashcat, john) +- Comprehensive logging + +## Requirements + +- Python 3.6 or higher +- colorama (optional, for coloured output) + +## Installation + +### Option 1: Install dependencies only + +```bash +pip install -r requirements.txt +``` ## Usage -`./keytabextract.py [file.keytab]` +``` +usage: keytabextract.py [-h] (-d DIRECTORY | [keytab]) [-o OUTPUT] [-v] + [-f {plain,hashcat,john}] [--no-colour] [--log LOG] + [--log-level {DEBUG,INFO,WARNING,ERROR}] [--dry-run] + +KeyTabExtract: Extract hashes from Kerberos keytab files with timestamps + +positional arguments: + keytab Path to the keytab file + +optional arguments: + -h, --help show this help message and exit + -d DIRECTORY, --directory DIRECTORY + Process all .keytab files in the specified directory + -o OUTPUT, --output OUTPUT + Save results to the specified file or directory (for batch mode) + -v, --verbose Enable verbose output + -f {plain,hashcat,john}, --format {plain,hashcat,john} + Output format for hashes (plain, hashcat, or john) + --no-colour Disable coloured output + --log LOG Log file path + --log-level {DEBUG,INFO,WARNING,ERROR} + Set logging level + --dry-run Analyse the keytab file without extracting hashes +``` + +## Examples + +### Basic usage + +```bash +./keytabextract.py service.keytab +``` + +### Save results to a file + +```bash +./keytabextract.py -o hashes.txt service.keytab +``` + +### Format hashes for hashcat + +```bash +./keytabextract.py -f hashcat service.keytab +``` + +### Process all keytab files in a directory + +```bash +./keytabextract.py -d /path/to/keytabs -o output_dir +``` + +### Enable verbose output and logging + +```bash +./keytabextract.py -v --log keytab.log --log-level DEBUG service.keytab +``` + +## Output Format + +The tool displays information in a hierarchical format: + +``` +=== KeyTabExtract Results === +File: service.keytab +Version: 0502 + +Realm: EXAMPLE.COM + Service Principal: HTTP/server.example.com + Timestamp: 2023-04-30 15:45:23 (KVNO: 3) + NTLM: 8846f7eaee8fb117ad06bdd830b7586c + AES-128: 3f5b9e2f3ad16b2e11ca4d90d87d6a48 + Timestamp: 2023-01-15 09:12:05 (KVNO: 2) + NTLM: 2d8f65e0ce5f2d7c2d8f65e0ce5f2d7c +``` -## To Do -- Associate keytype values with their encryption type -- Associate Principal Type values with their names -- Add support for 0501 kerberos type files \ No newline at end of file From 9148b92b13637fb43bf04a67378229e8f2be9113 Mon Sep 17 00:00:00 2001 From: Andy | ZephrFish <5783068+ZephrFish@users.noreply.github.com> Date: Mon, 29 Sep 2025 22:49:49 +0100 Subject: [PATCH 5/6] Added updated gitignore, cleaned project structure with some additional changes to how management of different formats operates while maintaining how the thing actually works still --- .gitignore | 127 +++++++ README.md | 7 + keytabextract.py | 927 +++++++++++++++++++++++++++++------------------ 3 files changed, 712 insertions(+), 349 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b49094b --- /dev/null +++ b/.gitignore @@ -0,0 +1,127 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# IDE settings +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Keytab test files (for security) +*.keytab +test_*.keytab + +# Output files +output/ +*.txt +!requirements.txt + +# Temporary files +tmp/ +temp/ +*.tmp +*.temp \ No newline at end of file diff --git a/README.md b/README.md index 967fc66..5acb16c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ KeyTabExtract is a utility to help extract valuable information from Kerberos .k - Batch processing of multiple keytab files - Multiple hash format options (plain, hashcat, john) - Comprehensive logging +- Dry-run mode to analyse keytab structure without extracting hashes ## Requirements @@ -87,6 +88,12 @@ optional arguments: ./keytabextract.py -v --log keytab.log --log-level DEBUG service.keytab ``` +### Analyse keytab structure without extracting hashes (dry-run) + +```bash +./keytabextract.py --dry-run service.keytab +``` + ## Output Format The tool displays information in a hierarchical format: diff --git a/keytabextract.py b/keytabextract.py index 99fd003..75168b9 100755 --- a/keytabextract.py +++ b/keytabextract.py @@ -10,16 +10,20 @@ - Batch processing of multiple keytab files - Multiple hash format options (plain, hashcat, john) - Comprehensive logging +- Dry-run mode for analysis without extraction """ import argparse import binascii import datetime import logging -import os import re import sys -from typing import Dict, Any, Optional, List, Tuple, Set +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Dict, Any, Optional, List, Tuple # Try to import colorama for coloured output try: @@ -31,10 +35,10 @@ HAS_COLOURS = False # Create dummy colour constants class DummyFore: - def __getattr__(self, name): + def __getattr__(self, name: str) -> str: return "" class DummyStyle: - def __getattr__(self, name): + def __getattr__(self, name: str) -> str: return "" Fore = DummyFore() Style = DummyStyle() @@ -43,124 +47,453 @@ def __getattr__(self, name): logger = logging.getLogger("keytabextract") # Constants -ENCRYPTION_TYPES = { - "0017": {"name": "RC4-HMAC", "display": "NTLM", "hash_length": 32}, - "0012": {"name": "AES256-CTS-HMAC-SHA1", "display": "AES-256", "hash_length": 64}, - "0011": {"name": "AES128-CTS-HMAC-SHA1", "display": "AES-128", "hash_length": 32} +MAX_KEYTAB_SIZE: int = 100 * 1024 * 1024 # 100MB limit +HEADER_SIZE: int = 12 # Skip version and size fields +VERSION_FIELD_SIZE: int = 4 +COMPONENT_COUNT_SIZE: int = 4 +REALM_LENGTH_SIZE: int = 4 +COMPONENT_LENGTH_SIZE: int = 4 +TIMESTAMP_SIZE: int = 8 +KVNO_SIZE: int = 2 +KEYTYPE_SIZE: int = 4 +KEYLEN_SIZE: int = 4 +NAMETYPE_SIZE: int = 8 + +SUPPORTED_VERSIONS: List[str] = ["0501", "0502"] + + +class HashFormat(Enum): + """Output format for hashes.""" + PLAIN = "plain" + HASHCAT = "hashcat" + JOHN = "john" + + +class EncryptionType(Enum): + """Supported encryption types.""" + RC4_HMAC = "0017" + AES256_CTS_HMAC_SHA1 = "0012" + AES128_CTS_HMAC_SHA1 = "0011" + + +@dataclass +class EncryptionInfo: + """Information about an encryption type.""" + name: str + display: str + hash_length: int + pattern_suffix: str + + +# Encryption type definitions +ENCRYPTION_TYPES: Dict[str, EncryptionInfo] = { + EncryptionType.RC4_HMAC.value: EncryptionInfo( + name="RC4-HMAC", + display="NTLM", + hash_length=32, + pattern_suffix="0010" + ), + EncryptionType.AES256_CTS_HMAC_SHA1.value: EncryptionInfo( + name="AES256-CTS-HMAC-SHA1", + display="AES-256", + hash_length=64, + pattern_suffix="0020" + ), + EncryptionType.AES128_CTS_HMAC_SHA1.value: EncryptionInfo( + name="AES128-CTS-HMAC-SHA1", + display="AES-128", + hash_length=32, + pattern_suffix="0010" + ) } -SUPPORTED_VERSIONS = ["0501", "0502"] + +@dataclass +class KeyEntry: + """Represents a single key entry from a keytab.""" + timestamp: int + timestamp_str: str + kvno: int + encryption_type: str + hash_value: str + + def __lt__(self, other: 'KeyEntry') -> bool: + """Sort by timestamp (newest first).""" + return self.timestamp > other.timestamp + + +@dataclass +class ServicePrincipal: + """Represents a service principal with its keys.""" + name: str + realm: str + keys: List[KeyEntry] = field(default_factory=list) + + def add_key(self, key: KeyEntry) -> None: + """Add a key entry to this service principal.""" + self.keys.append(key) + self.keys.sort() # Keep sorted by timestamp + + +@dataclass +class KeytabData: + """Container for all extracted keytab data.""" + version: str + file_path: str + principals: Dict[str, ServicePrincipal] = field(default_factory=dict) + + def add_entry(self, realm: str, principal_name: str, key: KeyEntry) -> None: + """Add a key entry to the appropriate service principal.""" + full_name = f"{principal_name}@{realm}" + if full_name not in self.principals: + self.principals[full_name] = ServicePrincipal( + name=principal_name, + realm=realm + ) + self.principals[full_name].add_key(key) + + +class KeyTabParser(ABC): + """Abstract base class for version-specific keytab parsers.""" + + @abstractmethod + def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str, str, KeyEntry]], int]: + """ + Extract a single entry from the keytab. + + Returns: + Tuple containing (realm, principal, key_entry) and new pointer position + """ + pass + + +class KeyTabParserV0501(KeyTabParser): + """Parser for keytab version 0501.""" + + def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str, str, KeyEntry]], int]: + """Extract entry using v0501 format.""" + # For now, v0501 uses the same format as v0502 + # This would be implemented differently for actual v0501 format + parser = KeyTabParserV0502() + return parser.extract_entry(hex_data, pointer) + + +class KeyTabParserV0502(KeyTabParser): + """Parser for keytab version 0502.""" + + def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str, str, KeyEntry]], int]: + """Extract entry using v0502 format.""" + try: + # Number of components + num_components = int(hex_data[pointer:pointer+COMPONENT_COUNT_SIZE], 16) + pointer += COMPONENT_COUNT_SIZE + + # Realm length and value + realm_len = int(hex_data[pointer:pointer+REALM_LENGTH_SIZE], 16) + pointer += REALM_LENGTH_SIZE + + realm_end = pointer + (realm_len * 2) + realm = bytes.fromhex(hex_data[pointer:realm_end]).decode('utf-8') + pointer = realm_end + + # Extract components + components = [] + for _ in range(num_components): + comp_len = int(hex_data[pointer:pointer+COMPONENT_LENGTH_SIZE], 16) + pointer += COMPONENT_LENGTH_SIZE + comp_end = pointer + (comp_len * 2) + component = bytes.fromhex(hex_data[pointer:comp_end]).decode('utf-8') + components.append(component) + pointer = comp_end + + service_principal = "/".join(components) + + # Skip name type + pointer += NAMETYPE_SIZE + + # Timestamp + timestamp = int(hex_data[pointer:pointer+TIMESTAMP_SIZE], 16) + timestamp_str = datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + pointer += TIMESTAMP_SIZE + + # Key version number + kvno = int(hex_data[pointer:pointer+KVNO_SIZE], 16) + pointer += KVNO_SIZE + + # Key type + keytype_hex = hex_data[pointer:pointer+KEYTYPE_SIZE] + pointer += KEYTYPE_SIZE + + # Key length and value + key_len = int(hex_data[pointer:pointer+KEYLEN_SIZE], 16) + pointer += KEYLEN_SIZE + + key_val_end = pointer + (key_len * 2) + key_val = hex_data[pointer:key_val_end] + pointer = key_val_end + + # Create key entry + key = KeyEntry( + timestamp=timestamp, + timestamp_str=timestamp_str, + kvno=kvno, + encryption_type=keytype_hex, + hash_value=key_val + ) + + # Skip padding and find next entry + pointer = self._skip_padding(hex_data, pointer) + + return (realm, service_principal, key), pointer + + except Exception as e: + logger.debug(f"Error parsing entry at position {pointer}: {str(e)}") + return None, pointer + 8 + + def _skip_padding(self, hex_data: str, pointer: int) -> int: + """Skip padding bytes and alignment.""" + try: + # Try to skip next size field + if pointer + 8 <= len(hex_data): + pointer += 8 + + # Skip alignment bytes + while pointer < len(hex_data) and hex_data[pointer:pointer+2] == "00": + pointer += 2 + + # Handle special marker + if pointer < len(hex_data) and hex_data[pointer:pointer+4] == "ffff": + pointer += 8 + + except ValueError: + # Skip any padding bytes + while pointer < len(hex_data) and hex_data[pointer:pointer+2] == "00": + pointer += 2 + + if pointer < len(hex_data) and hex_data[pointer:pointer+4] == "ffff": + pointer += 8 + + return pointer + + +class HashFormatter: + """Formats hashes according to specified output format.""" + + @staticmethod + def format( + hash_format: HashFormat, + enc_type: str, + hash_value: str, + realm: str, + service_principal: str + ) -> str: + """Format a hash according to the specified output format.""" + if hash_format == HashFormat.PLAIN: + return hash_value + elif hash_format == HashFormat.HASHCAT: + return HashFormatter._format_hashcat(enc_type, hash_value, realm, service_principal) + elif hash_format == HashFormat.JOHN: + return HashFormatter._format_john(enc_type, hash_value, realm, service_principal) + return hash_value + + @staticmethod + def _format_hashcat(enc_type: str, hash_value: str, realm: str, principal: str) -> str: + """Format for hashcat.""" + if enc_type == EncryptionType.RC4_HMAC.value: + return f"{hash_value}:{principal}" + elif enc_type in (EncryptionType.AES256_CTS_HMAC_SHA1.value, EncryptionType.AES128_CTS_HMAC_SHA1.value): + return f"{hash_value}:{principal}:{realm}" + return hash_value + + @staticmethod + def _format_john(enc_type: str, hash_value: str, realm: str, principal: str) -> str: + """Format for John the Ripper.""" + if enc_type == EncryptionType.RC4_HMAC.value: + return f"{principal}:{hash_value}" + elif enc_type in (EncryptionType.AES256_CTS_HMAC_SHA1.value, EncryptionType.AES128_CTS_HMAC_SHA1.value): + return f"{principal}@{realm}:{hash_value}" + return f"{principal}:{hash_value}" class KeyTabExtractor: """Extract and process hashes from Kerberos keytab files.""" - - def __init__(self, keytab_path: str, verbose: bool = False, - no_colour: bool = False, hash_format: str = "plain"): + + def __init__( + self, + keytab_path: str, + verbose: bool = False, + no_colour: bool = False, + hash_format: HashFormat = HashFormat.PLAIN, + dry_run: bool = False + ): """ Initialise the KeyTabExtractor. - + Args: keytab_path: Path to the keytab file verbose: Enable verbose output no_colour: Disable coloured output - hash_format: Format for hash output (plain, hashcat, john) + hash_format: Format for hash output + dry_run: Analyse without extracting hashes """ - self.keytab_path = keytab_path - self.hex_encoded = "" - self.all_data = {} - self.verbose = verbose - self.use_colour = HAS_COLOURS and not no_colour - self.hash_format = hash_format - self.version = None - - def colour_text(self, text: str, colour) -> str: + self.keytab_path: str = keytab_path + self.hex_encoded: str = "" + self.keytab_data: Optional[KeytabData] = None + self.verbose: bool = verbose + self.use_colour: bool = HAS_COLOURS and not no_colour + self.hash_format: HashFormat = hash_format + self.dry_run: bool = dry_run + self.parser: Optional[KeyTabParser] = None + + def colour_text(self, text: str, colour: Any) -> str: """Apply colour to text if colours are enabled.""" if self.use_colour: return f"{colour}{text}{Style.RESET_ALL}" return text - - def log_info(self, message: str): + + def log_info(self, message: str) -> None: """Log an info message.""" logger.info(message) print(self.colour_text(f"[+] {message}", Fore.GREEN)) - - def log_warning(self, message: str): + + def log_warning(self, message: str) -> None: """Log a warning message.""" logger.warning(message) print(self.colour_text(f"[!] {message}", Fore.YELLOW)) - - def log_error(self, message: str): + + def log_error(self, message: str) -> None: """Log an error message.""" logger.error(message) print(self.colour_text(f"[!] {message}", Fore.RED)) - - def log_debug(self, message: str): + + def log_debug(self, message: str) -> None: """Log a debug message if verbose is enabled.""" logger.debug(message) if self.verbose: print(self.colour_text(f"[*] {message}", Fore.CYAN)) - + def load_keytab(self) -> bool: """ Load and validate the keytab file. - + Returns: bool: True if the file was successfully loaded, False otherwise """ try: - with open(self.keytab_path, 'rb') as f: + file_path = Path(self.keytab_path) + + # Check file existence and permissions + if not file_path.exists(): + self.log_error(f"File '{self.keytab_path}' not found.") + return False + + if not file_path.is_file(): + self.log_error(f"'{self.keytab_path}' is not a regular file.") + return False + + # Read file + with open(file_path, 'rb') as f: data = f.read() + + # Check file size + if len(data) > MAX_KEYTAB_SIZE: + self.log_error(f"Keytab file exceeds maximum size of {MAX_KEYTAB_SIZE} bytes.") + return False + + if len(data) < HEADER_SIZE: + self.log_error("Keytab file is too small to be valid.") + return False + self.hex_encoded = binascii.hexlify(data).decode('utf-8') - + # Validate keytab version - self.version = self.hex_encoded[:4] - if self.version not in SUPPORTED_VERSIONS: - self.log_error(f"Unsupported keytab version: {self.version}. " - f"Only versions {', '.join(SUPPORTED_VERSIONS)} are supported.") + version = self.hex_encoded[:VERSION_FIELD_SIZE] + if version not in SUPPORTED_VERSIONS: + self.log_error( + f"Unsupported keytab version: {version}. " + f"Only versions {', '.join(SUPPORTED_VERSIONS)} are supported." + ) return False - - self.log_info(f"Keytab file '{self.keytab_path}' successfully loaded " - f"(version {self.version}).") + + # Initialise data container and parser + self.keytab_data = KeytabData( + version=version, + file_path=self.keytab_path + ) + + # Select appropriate parser + if version == "0501": + self.parser = KeyTabParserV0501() + else: + self.parser = KeyTabParserV0502() + + self.log_info(f"Keytab file '{self.keytab_path}' successfully loaded (version {version}).") return True - except FileNotFoundError: - self.log_error(f"File '{self.keytab_path}' not found.") - return False + except PermissionError: self.log_error(f"Permission denied when accessing '{self.keytab_path}'.") return False except Exception as e: self.log_error(f"Error loading keytab file: {str(e)}") return False - + + def analyse_keytab(self) -> Dict[str, Any]: + """ + Analyse the keytab file structure without extracting hashes. + + Returns: + Dictionary with analysis results + """ + analysis: Dict[str, Any] = { + "version": self.keytab_data.version if self.keytab_data else "unknown", + "file_size": len(self.hex_encoded) // 2 if self.hex_encoded else 0, + "encryption_types": [], + "entry_count": 0, + "potential_principals": set() + } + + # Detect encryption types + for enc_id, enc_info in ENCRYPTION_TYPES.items(): + enc_pattern = f"{enc_id}{enc_info.pattern_suffix}" + if enc_pattern in self.hex_encoded: + analysis["encryption_types"].append(enc_info.name) + + # Count potential entries (rough estimate) + analysis["entry_count"] = sum( + self.hex_encoded.count(enc_type) + for enc_type in ENCRYPTION_TYPES.keys() + ) + + return analysis + def detect_encryption_types(self) -> Dict[str, bool]: """ Detect supported encryption types in the keytab. - + Returns: Dict mapping encryption type IDs to boolean indicating presence """ - found_types = {} - + found_types: Dict[str, bool] = {} + for enc_id, enc_info in ENCRYPTION_TYPES.items(): - enc_pattern = f"{enc_id}0010" if enc_id == "0017" else f"{enc_id}0020" if enc_id == "0012" else f"{enc_id}0010" + enc_pattern = f"{enc_id}{enc_info.pattern_suffix}" if enc_pattern in self.hex_encoded: - self.log_info(f"{enc_info['name']} encryption detected. Will attempt to extract hash.") + self.log_info(f"{enc_info.name} encryption detected. Will attempt to extract hash.") found_types[enc_id] = True else: - self.log_debug(f"No {enc_info['name']} encryption found.") + self.log_debug(f"No {enc_info.name} encryption found.") found_types[enc_id] = False - + return found_types - + def verify_hash(self, enc_type: str, hash_value: str) -> bool: """ Verify that a hash meets the expected format requirements. - + Args: enc_type: Encryption type ID hash_value: Hash value to verify - + Returns: bool: True if the hash is valid, False otherwise """ @@ -168,393 +501,258 @@ def verify_hash(self, enc_type: str, hash_value: str) -> bool: if enc_type not in ENCRYPTION_TYPES: self.log_debug(f"Unknown encryption type: {enc_type}") return False - + # Check hash length - expected_length = ENCRYPTION_TYPES[enc_type]["hash_length"] + expected_length = ENCRYPTION_TYPES[enc_type].hash_length if len(hash_value) != expected_length: - self.log_debug(f"Invalid hash length for {ENCRYPTION_TYPES[enc_type]['name']}: " - f"expected {expected_length}, got {len(hash_value)}") + self.log_debug( + f"Invalid hash length for {ENCRYPTION_TYPES[enc_type].name}: " + f"expected {expected_length}, got {len(hash_value)}" + ) return False - + # Check for valid hex characters - if not all(c in "0123456789abcdefABCDEF" for c in hash_value): - self.log_debug(f"Invalid characters in hash: {hash_value}") - return False - - return True - - def format_hash(self, enc_type: str, hash_value: str, - realm: str, service_principal: str) -> str: - """ - Format a hash according to the specified output format. - - Args: - enc_type: Encryption type ID - hash_value: Hash value to format - realm: Kerberos realm - service_principal: Service principal name - - Returns: - str: Formatted hash - """ - enc_name = ENCRYPTION_TYPES.get(enc_type, {}).get("name", f"Type-{enc_type}") - - if self.hash_format == "plain": - return hash_value - elif self.hash_format == "hashcat": - if enc_type == "0017": # RC4-HMAC - return f"{hash_value}:{service_principal}" - elif enc_type == "0012": # AES256 - return f"{hash_value}:{service_principal}:{realm}" - elif enc_type == "0011": # AES128 - return f"{hash_value}:{service_principal}:{realm}" - else: - return hash_value - elif self.hash_format == "john": - if enc_type == "0017": # RC4-HMAC - return f"{service_principal}:{hash_value}" - elif enc_type == "0012" or enc_type == "0011": # AES - return f"{service_principal}@{realm}:{hash_value}" - else: - return f"{service_principal}:{hash_value}" - else: - return hash_value - - def extract_entry_v0501(self, pointer: int) -> int: - """ - Extract a keytab entry using v0501 format. - - Args: - pointer: Current position in the hex string - - Returns: - int: New pointer position after processing this entry - """ - # Implementation for v0501 format - # This is a placeholder - actual implementation would need to handle - # the specific format differences of v0501 - self.log_debug(f"Parsing v0501 entry at position {pointer}") - return self.extract_entry_v0502(pointer) - - def extract_entry_v0502(self, pointer: int) -> int: - """ - Extract a keytab entry using v0502 format. - - Args: - pointer: Current position in the hex string - - Returns: - int: New pointer position after processing this entry - """ try: - # Number of components - num_components = int(self.hex_encoded[pointer:pointer+4], 16) - self.log_debug(f"Number of components: {num_components}") - - # Realm length - realm_len = int(self.hex_encoded[pointer+4:pointer+8], 16) - self.log_debug(f"Realm length: {realm_len}") - - # Realm value - realm_end = pointer+8 + (realm_len * 2) - realm = bytes.fromhex(self.hex_encoded[pointer+8:realm_end]).decode('utf-8') - self.log_debug(f"Realm: {realm}") - - # Extract components - components = [] - comp_start = realm_end - comp_end = comp_start - for i in range(num_components): - comp_len = int(self.hex_encoded[comp_start:comp_start+4], 16) - comp_end = comp_start+4 + (comp_len * 2) - components.append(self.hex_encoded[comp_start+4:comp_end]) - comp_start = comp_end - self.log_debug(f"Component {i+1} length: {comp_len}") - - # Convert components to strings and join - components = [bytes.fromhex(x).decode('utf-8') for x in components] - service_principal = "/".join(components) - self.log_debug(f"Service principal: {service_principal}") - - # Name type - typename_offset = comp_end + 8 - name_type = int(self.hex_encoded[comp_end:typename_offset], 16) - self.log_debug(f"Name type: {name_type}") - - # Timestamp - timestamp_offset = typename_offset + 8 - timestamp = int(self.hex_encoded[typename_offset:timestamp_offset], 16) - timestamp_str = datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") - self.log_debug(f"Timestamp: {timestamp_str}") + bytes.fromhex(hash_value) + except ValueError: + self.log_debug(f"Invalid hex characters in hash: {hash_value}") + return False - # Key version number - vno_offset = timestamp_offset + 2 - vno = int(self.hex_encoded[timestamp_offset:vno_offset], 16) - self.log_debug(f"KVNO: {vno}") + return True - # Key type - keytype_offset = vno_offset + 4 - keytype_hex = self.hex_encoded[vno_offset:keytype_offset] - self.log_debug(f"Key type: {keytype_hex}") - - # Key length - key_val_offset = keytype_offset + 4 - key_val_len = int(self.hex_encoded[keytype_offset:key_val_offset], 16) - self.log_debug(f"Key length: {key_val_len}") - - # Key value - key_val_start = key_val_offset - key_val_finish = key_val_start + (key_val_len * 2) - key_val = self.hex_encoded[key_val_start:key_val_finish] - self.log_debug(f"Key value: {key_val}") - - # Verify hash - if self.verify_hash(keytype_hex, key_val): - # Store extracted data - if not realm in self.all_data: - self.all_data[realm] = {} - if not service_principal in self.all_data[realm]: - self.all_data[realm][service_principal] = {} - if not timestamp_str in self.all_data[realm][service_principal]: - self.all_data[realm][service_principal][timestamp_str] = { - "timestamp": timestamp, # Store raw timestamp for sorting - "kvno": vno, - "keys": {} - } - if not keytype_hex in self.all_data[realm][service_principal][timestamp_str]["keys"]: - self.all_data[realm][service_principal][timestamp_str]["keys"][keytype_hex] = key_val - else: - self.log_warning(f"Invalid hash found for {service_principal}, type {keytype_hex}") - - # Calculate next entry position - next_entry = key_val_finish - - # Skip padding and alignment bytes - try: - # Try to read next size field - next_size = int(self.hex_encoded[next_entry:next_entry+8], 16) - next_entry += 8 - - # Skip alignment bytes if needed - while next_entry < len(self.hex_encoded) and self.hex_encoded[next_entry:next_entry+2] == "00": - next_entry += 2 - - # Handle special marker - if next_entry < len(self.hex_encoded) and self.hex_encoded[next_entry:next_entry+4] == "ffff": - next_entry += 8 - except ValueError: - # If we can't parse the next size, try to find the next valid entry - while next_entry < len(self.hex_encoded) and self.hex_encoded[next_entry:next_entry+2] == "00": - next_entry += 2 - - if next_entry < len(self.hex_encoded) and self.hex_encoded[next_entry:next_entry+4] == "ffff": - next_entry += 8 - - return next_entry - except Exception as e: - self.log_error(f"Error parsing entry at position {pointer}: {str(e)}") - # Try to advance to next entry - return pointer + 8 - def extract_entries(self) -> bool: """ Extract all entries from the keytab file. - + Returns: bool: True if any entries were extracted, False otherwise """ - pointer = 12 # Skip version and size fields + if self.dry_run: + self.log_info("Dry-run mode: Analysing structure without extracting hashes") + analysis = self.analyse_keytab() + self.log_info(f"Analysis results:") + self.log_info(f" Version: {analysis['version']}") + self.log_info(f" File size: {analysis['file_size']} bytes") + self.log_info(f" Encryption types: {', '.join(analysis['encryption_types']) if analysis['encryption_types'] else 'None detected'}") + self.log_info(f" Estimated entries: {analysis['entry_count']}") + return analysis['entry_count'] > 0 + + if not self.parser or not self.keytab_data: + self.log_error("Parser not initialised.") + return False + + pointer = HEADER_SIZE entry_count = 0 - + try: while pointer < len(self.hex_encoded): - if self.version == "0501": - new_pointer = self.extract_entry_v0501(pointer) - else: # 0502 - new_pointer = self.extract_entry_v0502(pointer) - + result, new_pointer = self.parser.extract_entry(self.hex_encoded, pointer) + + if result: + realm, principal, key_entry = result + + # Verify hash before adding + if self.verify_hash(key_entry.encryption_type, key_entry.hash_value): + self.keytab_data.add_entry(realm, principal, key_entry) + entry_count += 1 + else: + self.log_warning( + f"Invalid hash found for {principal}, type {key_entry.encryption_type}" + ) + if new_pointer <= pointer: # Avoid infinite loop self.log_warning(f"Parser stuck at position {pointer}. Stopping.") break - + pointer = new_pointer - entry_count += 1 - - self.log_info(f"Processed {entry_count} entries from keytab file.") + + self.log_info(f"Processed {entry_count} valid entries from keytab file.") return entry_count > 0 + except Exception as e: self.log_error(f"Error during extraction: {str(e)}") return False - + def format_output(self, output_file: Optional[str] = None) -> bool: """ Format and display the extracted data. - + Args: output_file: Optional path to save results - + Returns: bool: True if successful, False otherwise """ - if not self.all_data: + if self.dry_run: + # Dry-run output is handled in extract_entries + return True + + if not self.keytab_data or not self.keytab_data.principals: self.log_error("No valid entries found in keytab file.") return False - - output_lines = [] - - def add_line(line): + + output_lines: List[str] = [] + + def add_line(line: str) -> None: output_lines.append(line) print(line) - + add_line("\n" + self.colour_text("=== KeyTabExtract Results ===", Fore.CYAN)) - add_line(f"File: {self.keytab_path}") - add_line(f"Version: {self.version}") + add_line(f"File: {self.keytab_data.file_path}") + add_line(f"Version: {self.keytab_data.version}") add_line("") - - for realm in self.all_data: - add_line(self.colour_text(f"Realm: {realm}", Fore.MAGENTA)) - for sp in self.all_data[realm]: - add_line(self.colour_text(f" Service Principal: {sp}", Fore.BLUE)) - - # Sort timestamps by the actual timestamp value (newest first) - sorted_timestamps = sorted( - self.all_data[realm][sp].keys(), - key=lambda ts: self.all_data[realm][sp][ts]["timestamp"], - reverse=True + + # Sort principals for consistent output + for principal_name in sorted(self.keytab_data.principals.keys()): + principal = self.keytab_data.principals[principal_name] + add_line(self.colour_text(f"Realm: {principal.realm}", Fore.MAGENTA)) + add_line(self.colour_text(f" Service Principal: {principal.name}", Fore.BLUE)) + + # Keys are already sorted by timestamp (newest first) + for key in principal.keys: + add_line( + self.colour_text( + f" Timestamp: {key.timestamp_str} (KVNO: {key.kvno})", + Fore.YELLOW + ) ) - - for timestamp in sorted_timestamps: - entry = self.all_data[realm][sp][timestamp] - add_line(self.colour_text(f" Timestamp: {timestamp} (KVNO: {entry['kvno']})", Fore.YELLOW)) - - for enctype in sorted(entry["keys"].keys()): - key_value = entry["keys"][enctype] - display_name = ENCRYPTION_TYPES.get(enctype, {}).get("display", f"Type-{enctype}") - - # Format hash according to selected format - formatted_hash = self.format_hash(enctype, key_value, realm, sp) - - add_line(f" {display_name}: {formatted_hash}") - + + # Get encryption info + enc_info = ENCRYPTION_TYPES.get(key.encryption_type) + display_name = enc_info.display if enc_info else f"Type-{key.encryption_type}" + + # Format hash + formatted_hash = HashFormatter.format( + self.hash_format, + key.encryption_type, + key.hash_value, + principal.realm, + principal.name + ) + + add_line(f" {display_name}: {formatted_hash}") + # Save to file if requested if output_file: try: - with open(output_file, 'w') as f: + output_path = Path(output_file) + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, 'w') as f: for line in output_lines: # Strip ANSI colour codes for file output - clean_line = re.sub(r'\x1b\[\d+m', '', line) + clean_line = re.sub(r'\x1b\[[0-9;]+m', '', line) f.write(clean_line + "\n") + self.log_info(f"Results saved to {output_file}") return True + except Exception as e: self.log_error(f"Error saving to file: {str(e)}") return False - + return True - + def run(self, output_file: Optional[str] = None) -> int: """ Main execution flow. - + Args: output_file: Optional path to save results - + Returns: int: Exit code (0 for success, non-zero for errors) """ if not self.load_keytab(): return 1 - + if not self.detect_encryption_types(): self.log_warning("No supported encryption types found.") return 1 - + if not self.extract_entries(): - self.log_error("Failed to extract entries from keytab file.") + if not self.dry_run: + self.log_error("Failed to extract entries from keytab file.") return 1 - + if not self.format_output(output_file): return 1 - + return 0 def process_directory(directory: str, args: argparse.Namespace) -> int: """ Process all keytab files in a directory. - + Args: directory: Directory path to scan for keytab files args: Command line arguments - + Returns: int: Exit code (0 for success, non-zero for errors) """ - if not os.path.isdir(directory): + dir_path = Path(directory) + + if not dir_path.is_dir(): logger.error(f"Directory not found: {directory}") print(f"[!] Directory not found: {directory}") return 1 - - success_count = 0 - failure_count = 0 - keytab_files = [] - - # Find all keytab files - for root, _, files in os.walk(directory): - for filename in files: - if filename.endswith('.keytab'): - keytab_files.append(os.path.join(root, filename)) - + + success_count: int = 0 + failure_count: int = 0 + keytab_files: List[Path] = list(dir_path.rglob("*.keytab")) + if not keytab_files: logger.warning(f"No .keytab files found in {directory}") print(f"[!] No .keytab files found in {directory}") return 1 - + logger.info(f"Found {len(keytab_files)} keytab files in {directory}") print(f"[+] Found {len(keytab_files)} keytab files in {directory}") - + # Process each keytab file for filepath in keytab_files: print(f"\n[*] Processing {filepath}...") logger.info(f"Processing {filepath}") - + # Create output filename if needed - output_file = None + output_file: Optional[str] = None if args.output: - base_name = os.path.splitext(os.path.basename(filepath))[0] - output_dir = args.output - os.makedirs(output_dir, exist_ok=True) - output_file = os.path.join(output_dir, f"{base_name}.txt") - + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + output_file = str(output_dir / f"{filepath.stem}.txt") + extractor = KeyTabExtractor( - filepath, + str(filepath), verbose=args.verbose, no_colour=args.no_colour, - hash_format=args.format + hash_format=HashFormat(args.format), + dry_run=args.dry_run ) result = extractor.run(output_file) - + if result == 0: success_count += 1 else: failure_count += 1 - + logger.info(f"Batch processing complete: {success_count} successful, {failure_count} failed") print(f"\n[*] Batch processing complete: {success_count} successful, {failure_count} failed") return 0 if failure_count == 0 else 1 -def setup_logging(log_file: Optional[str], log_level: str): +def setup_logging(log_file: Optional[str], log_level: str) -> None: """ Configure logging. - + Args: log_file: Path to log file or None for console logging log_level: Logging level (DEBUG, INFO, WARNING, ERROR) """ numeric_level = getattr(logging, log_level.upper(), logging.INFO) - + if log_file: logging.basicConfig( filename=log_file, @@ -572,7 +770,7 @@ def setup_logging(log_file: Optional[str], log_level: str): ) -def parse_arguments(): +def parse_arguments() -> argparse.Namespace: """Parse command line arguments.""" parser = argparse.ArgumentParser( description="KeyTabExtract: Extract hashes from Kerberos keytab files with timestamps", @@ -584,53 +782,84 @@ def parse_arguments(): %(prog)s -v -f hashcat service.keytab %(prog)s -d /path/to/keytabs -o output_dir %(prog)s --log keytab.log --log-level DEBUG service.keytab + %(prog)s --dry-run service.keytab # Analyse without extracting """ ) - + input_group = parser.add_mutually_exclusive_group(required=True) input_group.add_argument("keytab", nargs="?", help="Path to the keytab file") - input_group.add_argument("-d", "--directory", help="Process all .keytab files in the specified directory") - - parser.add_argument("-o", "--output", help="Save results to the specified file or directory (for batch mode)") - parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output") - parser.add_argument("-f", "--format", choices=["plain", "hashcat", "john"], default="plain", - help="Output format for hashes (plain, hashcat, or john)") - parser.add_argument("--no-colour", action="store_true", help="Disable coloured output") - parser.add_argument("--log", help="Log file path") - parser.add_argument("--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"], - default="INFO", help="Set logging level") - parser.add_argument("--dry-run", action="store_true", - help="Analyse the keytab file without extracting hashes") - + input_group.add_argument( + "-d", "--directory", + help="Process all .keytab files in the specified directory" + ) + + parser.add_argument( + "-o", "--output", + help="Save results to the specified file or directory (for batch mode)" + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Enable verbose output" + ) + parser.add_argument( + "-f", "--format", + choices=["plain", "hashcat", "john"], + default="plain", + help="Output format for hashes (plain, hashcat, or john)" + ) + parser.add_argument( + "--no-colour", + action="store_true", + help="Disable coloured output" + ) + parser.add_argument( + "--log", + help="Log file path" + ) + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + default="INFO", + help="Set logging level" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Analyse the keytab file structure without extracting hashes" + ) + args = parser.parse_args() - + # Validate arguments if not args.keytab and not args.directory: parser.error("Either a keytab file or directory must be specified") - + return args -def main(): +def main() -> int: """Main entry point for the script.""" args = parse_arguments() - + # Setup logging setup_logging(args.log, args.log_level) logger.info(f"KeyTabExtract started with arguments: {vars(args)}") - + try: # Process directory or single file if args.directory: return process_directory(args.directory, args) else: extractor = KeyTabExtractor( - args.keytab, + args.keytab, verbose=args.verbose, no_colour=args.no_colour, - hash_format=args.format + hash_format=HashFormat(args.format), + dry_run=args.dry_run ) return extractor.run(args.output) + except KeyboardInterrupt: logger.warning("Operation interrupted by user") print("\n[!] Operation interrupted by user") @@ -644,4 +873,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main()) \ No newline at end of file From da343109383f7b52c89d1774ac000823b53c28f5 Mon Sep 17 00:00:00 2001 From: Andy | ZephrFish Date: Mon, 29 Sep 2025 23:18:36 +0100 Subject: [PATCH 6/6] Added additional formatting fixes --- keytabextract.py | 249 ++++++++++++++++++++++++++++++----------------- 1 file changed, 162 insertions(+), 87 deletions(-) diff --git a/keytabextract.py b/keytabextract.py index 75168b9..5cd774e 100755 --- a/keytabextract.py +++ b/keytabextract.py @@ -33,7 +33,6 @@ HAS_COLOURS = True except ImportError: HAS_COLOURS = False - # Create dummy colour constants class DummyFore: def __getattr__(self, name: str) -> str: return "" @@ -43,12 +42,10 @@ def __getattr__(self, name: str) -> str: Fore = DummyFore() Style = DummyStyle() -# Configure logger logger = logging.getLogger("keytabextract") -# Constants -MAX_KEYTAB_SIZE: int = 100 * 1024 * 1024 # 100MB limit -HEADER_SIZE: int = 12 # Skip version and size fields +MAX_KEYTAB_SIZE: int = 100 * 1024 * 1024 +HEADER_SIZE: int = 12 VERSION_FIELD_SIZE: int = 4 COMPONENT_COUNT_SIZE: int = 4 REALM_LENGTH_SIZE: int = 4 @@ -130,9 +127,13 @@ class ServicePrincipal: keys: List[KeyEntry] = field(default_factory=list) def add_key(self, key: KeyEntry) -> None: - """Add a key entry to this service principal.""" + """Add a key entry to this service principal. + + Args: + key: KeyEntry to add + """ self.keys.append(key) - self.keys.sort() # Keep sorted by timestamp + self.keys.sort() @dataclass @@ -143,7 +144,13 @@ class KeytabData: principals: Dict[str, ServicePrincipal] = field(default_factory=dict) def add_entry(self, realm: str, principal_name: str, key: KeyEntry) -> None: - """Add a key entry to the appropriate service principal.""" + """Add a key entry to the appropriate service principal. + + Args: + realm: Kerberos realm + principal_name: Service principal name + key: KeyEntry to add + """ full_name = f"{principal_name}@{realm}" if full_name not in self.principals: self.principals[full_name] = ServicePrincipal( @@ -158,8 +165,7 @@ class KeyTabParser(ABC): @abstractmethod def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str, str, KeyEntry]], int]: - """ - Extract a single entry from the keytab. + """Extract a single entry from the keytab. Returns: Tuple containing (realm, principal, key_entry) and new pointer position @@ -171,11 +177,60 @@ class KeyTabParserV0501(KeyTabParser): """Parser for keytab version 0501.""" def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str, str, KeyEntry]], int]: - """Extract entry using v0501 format.""" - # For now, v0501 uses the same format as v0502 - # This would be implemented differently for actual v0501 format - parser = KeyTabParserV0502() - return parser.extract_entry(hex_data, pointer) + """Extract entry using v0501 format (without entry size fields).""" + try: + num_components = int(hex_data[pointer:pointer+COMPONENT_COUNT_SIZE], 16) + pointer += COMPONENT_COUNT_SIZE + + realm_len = int(hex_data[pointer:pointer+REALM_LENGTH_SIZE], 16) + pointer += REALM_LENGTH_SIZE + + realm_end = pointer + (realm_len * 2) + realm = bytes.fromhex(hex_data[pointer:realm_end]).decode('utf-8') + pointer = realm_end + + components = [] + for _ in range(num_components): + comp_len = int(hex_data[pointer:pointer+COMPONENT_LENGTH_SIZE], 16) + pointer += COMPONENT_LENGTH_SIZE + comp_end = pointer + (comp_len * 2) + component = bytes.fromhex(hex_data[pointer:comp_end]).decode('utf-8') + components.append(component) + pointer = comp_end + + service_principal = "/".join(components) + pointer += NAMETYPE_SIZE + + timestamp = int(hex_data[pointer:pointer+TIMESTAMP_SIZE], 16) + timestamp_str = datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + pointer += TIMESTAMP_SIZE + + kvno = int(hex_data[pointer:pointer+2], 16) + pointer += 2 + + keytype_hex = hex_data[pointer:pointer+KEYTYPE_SIZE] + pointer += KEYTYPE_SIZE + + key_len = int(hex_data[pointer:pointer+KEYLEN_SIZE], 16) + pointer += KEYLEN_SIZE + + key_val_end = pointer + (key_len * 2) + key_val = hex_data[pointer:key_val_end] + pointer = key_val_end + + key = KeyEntry( + timestamp=timestamp, + timestamp_str=timestamp_str, + kvno=kvno, + encryption_type=keytype_hex, + hash_value=key_val + ) + + return (realm, service_principal, key), pointer + + except Exception as e: + logger.debug(f"Error parsing v0501 entry at position {pointer}: {str(e)}") + return None, pointer + 8 class KeyTabParserV0502(KeyTabParser): @@ -184,11 +239,9 @@ class KeyTabParserV0502(KeyTabParser): def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str, str, KeyEntry]], int]: """Extract entry using v0502 format.""" try: - # Number of components num_components = int(hex_data[pointer:pointer+COMPONENT_COUNT_SIZE], 16) pointer += COMPONENT_COUNT_SIZE - # Realm length and value realm_len = int(hex_data[pointer:pointer+REALM_LENGTH_SIZE], 16) pointer += REALM_LENGTH_SIZE @@ -196,7 +249,6 @@ def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str realm = bytes.fromhex(hex_data[pointer:realm_end]).decode('utf-8') pointer = realm_end - # Extract components components = [] for _ in range(num_components): comp_len = int(hex_data[pointer:pointer+COMPONENT_LENGTH_SIZE], 16) @@ -207,24 +259,18 @@ def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str pointer = comp_end service_principal = "/".join(components) - - # Skip name type pointer += NAMETYPE_SIZE - # Timestamp timestamp = int(hex_data[pointer:pointer+TIMESTAMP_SIZE], 16) timestamp_str = datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") pointer += TIMESTAMP_SIZE - # Key version number kvno = int(hex_data[pointer:pointer+KVNO_SIZE], 16) pointer += KVNO_SIZE - # Key type keytype_hex = hex_data[pointer:pointer+KEYTYPE_SIZE] pointer += KEYTYPE_SIZE - # Key length and value key_len = int(hex_data[pointer:pointer+KEYLEN_SIZE], 16) pointer += KEYLEN_SIZE @@ -232,7 +278,6 @@ def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str key_val = hex_data[pointer:key_val_end] pointer = key_val_end - # Create key entry key = KeyEntry( timestamp=timestamp, timestamp_str=timestamp_str, @@ -241,7 +286,6 @@ def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str hash_value=key_val ) - # Skip padding and find next entry pointer = self._skip_padding(hex_data, pointer) return (realm, service_principal, key), pointer @@ -251,22 +295,26 @@ def extract_entry(self, hex_data: str, pointer: int) -> Tuple[Optional[Tuple[str return None, pointer + 8 def _skip_padding(self, hex_data: str, pointer: int) -> int: - """Skip padding bytes and alignment.""" + """Skip padding bytes and alignment. + + Args: + hex_data: Hex-encoded keytab data + pointer: Current position in hex data + + Returns: + int: New pointer position + """ try: - # Try to skip next size field if pointer + 8 <= len(hex_data): pointer += 8 - # Skip alignment bytes while pointer < len(hex_data) and hex_data[pointer:pointer+2] == "00": pointer += 2 - # Handle special marker if pointer < len(hex_data) and hex_data[pointer:pointer+4] == "ffff": pointer += 8 except ValueError: - # Skip any padding bytes while pointer < len(hex_data) and hex_data[pointer:pointer+2] == "00": pointer += 2 @@ -287,7 +335,18 @@ def format( realm: str, service_principal: str ) -> str: - """Format a hash according to the specified output format.""" + """Format a hash according to the specified output format. + + Args: + hash_format: Output format type + enc_type: Encryption type ID + hash_value: Hash value to format + realm: Kerberos realm + service_principal: Service principal name + + Returns: + str: Formatted hash string + """ if hash_format == HashFormat.PLAIN: return hash_value elif hash_format == HashFormat.HASHCAT: @@ -298,7 +357,17 @@ def format( @staticmethod def _format_hashcat(enc_type: str, hash_value: str, realm: str, principal: str) -> str: - """Format for hashcat.""" + """Format for hashcat. + + Args: + enc_type: Encryption type ID + hash_value: Hash value + realm: Kerberos realm + principal: Service principal name + + Returns: + str: Hashcat-formatted hash + """ if enc_type == EncryptionType.RC4_HMAC.value: return f"{hash_value}:{principal}" elif enc_type in (EncryptionType.AES256_CTS_HMAC_SHA1.value, EncryptionType.AES128_CTS_HMAC_SHA1.value): @@ -307,7 +376,17 @@ def _format_hashcat(enc_type: str, hash_value: str, realm: str, principal: str) @staticmethod def _format_john(enc_type: str, hash_value: str, realm: str, principal: str) -> str: - """Format for John the Ripper.""" + """Format for John the Ripper. + + Args: + enc_type: Encryption type ID + hash_value: Hash value + realm: Kerberos realm + principal: Service principal name + + Returns: + str: John-formatted hash + """ if enc_type == EncryptionType.RC4_HMAC.value: return f"{principal}:{hash_value}" elif enc_type in (EncryptionType.AES256_CTS_HMAC_SHA1.value, EncryptionType.AES128_CTS_HMAC_SHA1.value): @@ -326,8 +405,7 @@ def __init__( hash_format: HashFormat = HashFormat.PLAIN, dry_run: bool = False ): - """ - Initialise the KeyTabExtractor. + """Initialise the KeyTabExtractor. Args: keytab_path: Path to the keytab file @@ -346,35 +424,58 @@ def __init__( self.parser: Optional[KeyTabParser] = None def colour_text(self, text: str, colour: Any) -> str: - """Apply colour to text if colours are enabled.""" + """Apply colour to text if colours are enabled. + + Args: + text: Text to colorize + colour: Colorama colour object + + Returns: + str: Colored or plain text + """ if self.use_colour: return f"{colour}{text}{Style.RESET_ALL}" return text def log_info(self, message: str) -> None: - """Log an info message.""" + """Log an info message. + + Args: + message: Message to log + """ logger.info(message) print(self.colour_text(f"[+] {message}", Fore.GREEN)) def log_warning(self, message: str) -> None: - """Log a warning message.""" + """Log a warning message. + + Args: + message: Message to log + """ logger.warning(message) print(self.colour_text(f"[!] {message}", Fore.YELLOW)) def log_error(self, message: str) -> None: - """Log an error message.""" + """Log an error message. + + Args: + message: Message to log + """ logger.error(message) print(self.colour_text(f"[!] {message}", Fore.RED)) def log_debug(self, message: str) -> None: - """Log a debug message if verbose is enabled.""" + """Log a debug message if verbose is enabled. + + Args: + message: Message to log + """ logger.debug(message) if self.verbose: print(self.colour_text(f"[*] {message}", Fore.CYAN)) def load_keytab(self) -> bool: - """ - Load and validate the keytab file. + """Load and validate the keytab file. Returns: bool: True if the file was successfully loaded, False otherwise @@ -382,7 +483,6 @@ def load_keytab(self) -> bool: try: file_path = Path(self.keytab_path) - # Check file existence and permissions if not file_path.exists(): self.log_error(f"File '{self.keytab_path}' not found.") return False @@ -391,11 +491,9 @@ def load_keytab(self) -> bool: self.log_error(f"'{self.keytab_path}' is not a regular file.") return False - # Read file with open(file_path, 'rb') as f: data = f.read() - # Check file size if len(data) > MAX_KEYTAB_SIZE: self.log_error(f"Keytab file exceeds maximum size of {MAX_KEYTAB_SIZE} bytes.") return False @@ -406,7 +504,6 @@ def load_keytab(self) -> bool: self.hex_encoded = binascii.hexlify(data).decode('utf-8') - # Validate keytab version version = self.hex_encoded[:VERSION_FIELD_SIZE] if version not in SUPPORTED_VERSIONS: self.log_error( @@ -415,13 +512,11 @@ def load_keytab(self) -> bool: ) return False - # Initialise data container and parser self.keytab_data = KeytabData( version=version, file_path=self.keytab_path ) - # Select appropriate parser if version == "0501": self.parser = KeyTabParserV0501() else: @@ -438,8 +533,7 @@ def load_keytab(self) -> bool: return False def analyse_keytab(self) -> Dict[str, Any]: - """ - Analyse the keytab file structure without extracting hashes. + """Analyse the keytab file structure without extracting hashes. Returns: Dictionary with analysis results @@ -452,13 +546,11 @@ def analyse_keytab(self) -> Dict[str, Any]: "potential_principals": set() } - # Detect encryption types for enc_id, enc_info in ENCRYPTION_TYPES.items(): enc_pattern = f"{enc_id}{enc_info.pattern_suffix}" if enc_pattern in self.hex_encoded: analysis["encryption_types"].append(enc_info.name) - # Count potential entries (rough estimate) analysis["entry_count"] = sum( self.hex_encoded.count(enc_type) for enc_type in ENCRYPTION_TYPES.keys() @@ -467,8 +559,7 @@ def analyse_keytab(self) -> Dict[str, Any]: return analysis def detect_encryption_types(self) -> Dict[str, bool]: - """ - Detect supported encryption types in the keytab. + """Detect supported encryption types in the keytab. Returns: Dict mapping encryption type IDs to boolean indicating presence @@ -487,8 +578,7 @@ def detect_encryption_types(self) -> Dict[str, bool]: return found_types def verify_hash(self, enc_type: str, hash_value: str) -> bool: - """ - Verify that a hash meets the expected format requirements. + """Verify that a hash meets the expected format requirements. Args: enc_type: Encryption type ID @@ -497,12 +587,10 @@ def verify_hash(self, enc_type: str, hash_value: str) -> bool: Returns: bool: True if the hash is valid, False otherwise """ - # Check if encryption type is known if enc_type not in ENCRYPTION_TYPES: self.log_debug(f"Unknown encryption type: {enc_type}") return False - # Check hash length expected_length = ENCRYPTION_TYPES[enc_type].hash_length if len(hash_value) != expected_length: self.log_debug( @@ -511,7 +599,6 @@ def verify_hash(self, enc_type: str, hash_value: str) -> bool: ) return False - # Check for valid hex characters try: bytes.fromhex(hash_value) except ValueError: @@ -521,8 +608,7 @@ def verify_hash(self, enc_type: str, hash_value: str) -> bool: return True def extract_entries(self) -> bool: - """ - Extract all entries from the keytab file. + """Extract all entries from the keytab file. Returns: bool: True if any entries were extracted, False otherwise @@ -551,7 +637,6 @@ def extract_entries(self) -> bool: if result: realm, principal, key_entry = result - # Verify hash before adding if self.verify_hash(key_entry.encryption_type, key_entry.hash_value): self.keytab_data.add_entry(realm, principal, key_entry) entry_count += 1 @@ -561,7 +646,6 @@ def extract_entries(self) -> bool: ) if new_pointer <= pointer: - # Avoid infinite loop self.log_warning(f"Parser stuck at position {pointer}. Stopping.") break @@ -575,8 +659,7 @@ def extract_entries(self) -> bool: return False def format_output(self, output_file: Optional[str] = None) -> bool: - """ - Format and display the extracted data. + """Format and display the extracted data. Args: output_file: Optional path to save results @@ -585,7 +668,6 @@ def format_output(self, output_file: Optional[str] = None) -> bool: bool: True if successful, False otherwise """ if self.dry_run: - # Dry-run output is handled in extract_entries return True if not self.keytab_data or not self.keytab_data.principals: @@ -603,13 +685,11 @@ def add_line(line: str) -> None: add_line(f"Version: {self.keytab_data.version}") add_line("") - # Sort principals for consistent output for principal_name in sorted(self.keytab_data.principals.keys()): principal = self.keytab_data.principals[principal_name] add_line(self.colour_text(f"Realm: {principal.realm}", Fore.MAGENTA)) add_line(self.colour_text(f" Service Principal: {principal.name}", Fore.BLUE)) - # Keys are already sorted by timestamp (newest first) for key in principal.keys: add_line( self.colour_text( @@ -618,11 +698,9 @@ def add_line(line: str) -> None: ) ) - # Get encryption info enc_info = ENCRYPTION_TYPES.get(key.encryption_type) display_name = enc_info.display if enc_info else f"Type-{key.encryption_type}" - # Format hash formatted_hash = HashFormatter.format( self.hash_format, key.encryption_type, @@ -633,7 +711,6 @@ def add_line(line: str) -> None: add_line(f" {display_name}: {formatted_hash}") - # Save to file if requested if output_file: try: output_path = Path(output_file) @@ -641,7 +718,6 @@ def add_line(line: str) -> None: with open(output_path, 'w') as f: for line in output_lines: - # Strip ANSI colour codes for file output clean_line = re.sub(r'\x1b\[[0-9;]+m', '', line) f.write(clean_line + "\n") @@ -655,8 +731,7 @@ def add_line(line: str) -> None: return True def run(self, output_file: Optional[str] = None) -> int: - """ - Main execution flow. + """Main execution flow. Args: output_file: Optional path to save results @@ -683,8 +758,7 @@ def run(self, output_file: Optional[str] = None) -> int: def process_directory(directory: str, args: argparse.Namespace) -> int: - """ - Process all keytab files in a directory. + """Process all keytab files in a directory. Args: directory: Directory path to scan for keytab files @@ -712,12 +786,10 @@ def process_directory(directory: str, args: argparse.Namespace) -> int: logger.info(f"Found {len(keytab_files)} keytab files in {directory}") print(f"[+] Found {len(keytab_files)} keytab files in {directory}") - # Process each keytab file for filepath in keytab_files: print(f"\n[*] Processing {filepath}...") logger.info(f"Processing {filepath}") - # Create output filename if needed output_file: Optional[str] = None if args.output: output_dir = Path(args.output) @@ -744,8 +816,7 @@ def process_directory(directory: str, args: argparse.Namespace) -> int: def setup_logging(log_file: Optional[str], log_level: str) -> None: - """ - Configure logging. + """Configure logging. Args: log_file: Path to log file or None for console logging @@ -761,7 +832,6 @@ def setup_logging(log_file: Optional[str], log_level: str) -> None: datefmt='%Y-%m-%d %H:%M:%S' ) else: - # Configure a null handler if no log file is specified logging.basicConfig( level=numeric_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', @@ -771,7 +841,11 @@ def setup_logging(log_file: Optional[str], log_level: str) -> None: def parse_arguments() -> argparse.Namespace: - """Parse command line arguments.""" + """Parse command line arguments. + + Returns: + argparse.Namespace: Parsed command line arguments + """ parser = argparse.ArgumentParser( description="KeyTabExtract: Extract hashes from Kerberos keytab files with timestamps", formatter_class=argparse.RawDescriptionHelpFormatter, @@ -831,7 +905,6 @@ def parse_arguments() -> argparse.Namespace: args = parser.parse_args() - # Validate arguments if not args.keytab and not args.directory: parser.error("Either a keytab file or directory must be specified") @@ -839,15 +912,17 @@ def parse_arguments() -> argparse.Namespace: def main() -> int: - """Main entry point for the script.""" + """Main entry point for the script. + + Returns: + int: Exit code (0 for success, non-zero for errors) + """ args = parse_arguments() - # Setup logging setup_logging(args.log, args.log_level) logger.info(f"KeyTabExtract started with arguments: {vars(args)}") try: - # Process directory or single file if args.directory: return process_directory(args.directory, args) else: