From a8ae4c8810e2ed5af0a46bd258e0fbefef023b00 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Tue, 4 Nov 2025 16:25:37 +0000 Subject: [PATCH 01/26] feat: Add Kerberos user enumeration and validation --- nxc/protocols/ldap.py | 71 ++++++++++++++++++++++ nxc/protocols/ldap/kerberos.py | 100 +++++++++++++++++++++++++++++++ nxc/protocols/ldap/proto_args.py | 4 ++ 3 files changed, 175 insertions(+) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index f06f52a71c..55bd1d4a63 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -996,6 +996,77 @@ def asreproast(self): with open(self.args.asreproast, "a+") as hash_asreproast: hash_asreproast.write(f"{hash_TGT}\n") + def check_user(self): + """ + Check if a single username is valid via Kerberos AS-REQ request. + This checks user existence without triggering badPwdCount by analyzing KDC error responses. + """ + username = self.args.check_user.strip() + + self.logger.display(f"Checking username: {username}") + + kerberos_attacks = KerberosAttacks(self) + result = kerberos_attacks.check_user_exists(username) + + if result is True: + self.logger.highlight(f"[+] {username} - Valid username") + elif result is False: + self.logger.fail(f"[-] {username} - Invalid username (does not exist)") + else: + self.logger.fail(f"[!] {username} - Error during check") + + def enum_users(self): + """ + Enumerate valid domain usernames via Kerberos AS-REQ requests. + This checks user existence without triggering badPwdCount by analyzing KDC error responses. + """ + usernames = [] + + # Parse input - can be usernames or files containing usernames + for item in self.args.enum_users: + if os.path.isfile(item): + try: + with open(item, encoding="utf-8") as f: + usernames.extend(line.strip() for line in f if line.strip()) + self.logger.info(f"Loaded {len([line.strip() for line in open(item) if line.strip()])} usernames from file: {item}") + except Exception as e: + self.logger.fail(f"Failed to read file '{item}': {e}") + return + else: + usernames.append(item.strip()) + + if not usernames: + self.logger.fail("No usernames provided for enumeration") + return + + self.logger.display(f"Starting Kerberos user enumeration with {len(usernames)} username(s)") + + valid_users = [] + invalid_users = [] + errors = [] + + kerberos_attacks = KerberosAttacks(self) + + for username in usernames: + result = kerberos_attacks.check_user_exists(username) + + if result is True: + valid_users.append(username) + self.logger.highlight(f"[+] {username} - Valid username") + elif result is False: + invalid_users.append(username) + self.logger.verbose(f"[-] {username} - Invalid username") + else: + errors.append(username) + self.logger.fail(f"[!] {username} - Error during check") + + # Summary + self.logger.display("") + self.logger.success(f"Enumeration complete: {len(valid_users)} valid, {len(invalid_users)} invalid, {len(errors)} errors") + + if valid_users: + self.logger.display(f"Valid usernames: {', '.join(valid_users)}") + def kerberoasting(self): if self.args.no_preauth_targets: usernames = [] diff --git a/nxc/protocols/ldap/kerberos.py b/nxc/protocols/ldap/kerberos.py index c754d80ec4..02775ad4c4 100644 --- a/nxc/protocols/ldap/kerberos.py +++ b/nxc/protocols/ldap/kerberos.py @@ -307,3 +307,103 @@ def get_tgt_asroast(self, userName, requestPAC=True): else: hash_tgt += f"{hexlify(as_rep['enc-part']['cipher'].asOctets()[:16]).decode()}${hexlify(as_rep['enc-part']['cipher'].asOctets()[16:]).decode()}" return hash_tgt + + def check_user_exists(self, userName): + """ + Check if a user exists via Kerberos AS-REQ without pre-authentication. + Returns: + True: User exists (got KDC_ERR_PREAUTH_REQUIRED or AS_REP) + False: User does not exist (got KDC_ERR_C_PRINCIPAL_UNKNOWN) + None: Unexpected error occurred + """ + nxc_logger.debug(f"Checking if user {userName} exists via AS-REQ") + + client_name = Principal(userName, type=constants.PrincipalNameType.NT_PRINCIPAL.value) + as_req = AS_REQ() + + domain = self.targetDomain.upper() + server_name = Principal(f"krbtgt/{domain}", type=constants.PrincipalNameType.NT_PRINCIPAL.value) + + as_req["pvno"] = 5 + as_req["msg-type"] = int(constants.ApplicationTagNumbers.AS_REQ.value) + + req_body = seq_set(as_req, "req-body") + + opts = [constants.KDCOptions.forwardable.value] + req_body["kdc-options"] = constants.encodeFlags(opts) + + seq_set(req_body, "sname", server_name.components_to_asn1) + seq_set(req_body, "cname", client_name.components_to_asn1) + + if domain == "": + nxc_logger.error("Empty Domain not allowed in Kerberos") + return None + + req_body["realm"] = domain + + # Set time parameters + now = datetime.utcnow() + timedelta(days=1) if utc_failed else datetime.now(UTC) + timedelta(days=1) + req_body["till"] = KerberosTime.to_asn1(now) + req_body["rtime"] = KerberosTime.to_asn1(now) + req_body["nonce"] = random.getrandbits(31) + + # Request multiple encryption types to maximize compatibility + supported_ciphers = ( + int(constants.EncryptionTypes.rc4_hmac.value), + int(constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value), + int(constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value), + ) + seq_set_iter(req_body, "etype", supported_ciphers) + + message = encoder.encode(as_req) + + # If kdcHost isn't set, use the target IP for DNS resolution + if not self.kdcHost: + self.kdcHost = self.host + + try: + r = sendReceive(message, domain, self.kdcHost) + except KerberosError as e: + error_code = e.getErrorCode() + if error_code == constants.ErrorCodes.KDC_ERR_C_PRINCIPAL_UNKNOWN.value: + # User does not exist + nxc_logger.debug(f"User {userName} does not exist (KDC_ERR_C_PRINCIPAL_UNKNOWN)") + return False + elif error_code == constants.ErrorCodes.KDC_ERR_PREAUTH_REQUIRED.value: + # User exists and requires pre-authentication (normal) + nxc_logger.debug(f"User {userName} exists (KDC_ERR_PREAUTH_REQUIRED)") + return True + elif error_code == constants.ErrorCodes.KDC_ERR_CLIENT_REVOKED.value: + # User exists but account is disabled + nxc_logger.debug(f"User {userName} exists but account is disabled (KDC_ERR_CLIENT_REVOKED)") + return True + elif error_code == constants.ErrorCodes.KDC_ERR_KEY_EXPIRED.value: + # User exists but password expired + nxc_logger.debug(f"User {userName} exists but password expired (KDC_ERR_KEY_EXPIRED)") + return True + else: + # Unexpected error + nxc_logger.debug(f"Unexpected Kerberos error for {userName}: {e} (code: {error_code})") + return None + except Exception as e: + nxc_logger.debug(f"Unexpected error checking user {userName}: {e}") + return None + + # If we get here, we received a response (likely AS_REP) + # This means the user exists and doesn't require pre-auth + try: + as_rep = decoder.decode(r, asn1Spec=AS_REP())[0] + nxc_logger.debug(f"User {userName} exists (no pre-auth required, received AS_REP)") + return True + except Exception: + # Could be a different response type + try: + krb_error = decoder.decode(r, asn1Spec=KRB_ERROR())[0] + error_code = krb_error["error-code"] + nxc_logger.debug(f"User {userName} returned KRB_ERROR with code: {error_code}") + if error_code == constants.ErrorCodes.KDC_ERR_C_PRINCIPAL_UNKNOWN.value: + return False + return True + except Exception: + nxc_logger.debug(f"Unexpected response format for user {userName}") + return None diff --git a/nxc/protocols/ldap/proto_args.py b/nxc/protocols/ldap/proto_args.py index 51f4e01847..327409bfbe 100644 --- a/nxc/protocols/ldap/proto_args.py +++ b/nxc/protocols/ldap/proto_args.py @@ -16,6 +16,10 @@ def proto_args(parser, parents): kerberoasting_arg = egroup.add_argument("--kerberoasting", "--kerberoast", help="Output TGS ticket to crack with hashcat to file") kerberoast_users_arg = egroup.add_argument("--kerberoast-account", nargs="+", dest="kerberoast_account", action=get_conditional_action(_StoreAction), make_required=[], help="Target specific accounts for kerberoasting (sAMAccountNames or file containing sAMAccountNames)") egroup.add_argument("--no-preauth-targets", nargs=1, dest="no_preauth_targets", help="Targeted kerberoastable users") + + kgroup = ldap_parser.add_argument_group("Kerberos User Enumeration", "Enumerate valid usernames via Kerberos (no badPwdCount increment)") + kgroup.add_argument("--check-user", dest="check_user", help="Check if a single username is valid via Kerberos") + kgroup.add_argument("--enum-users", nargs="+", dest="enum_users", help="Enumerate multiple valid usernames via Kerberos (usernames or file containing usernames)") # Make kerberoast-users require kerberoasting kerberoast_users_arg.make_required = [kerberoasting_arg] From 7cf4e765730dc9c7025fc5f3a96afb3bdde56d7b Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:05:42 +0100 Subject: [PATCH 02/26] feat: Add progress bar for Kerberos user enumeration --- nxc/protocols/ldap.py | 82 ++++++++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 25 deletions(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 55bd1d4a63..13ae27d247 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -13,6 +13,7 @@ from dns import resolver from dateutil.relativedelta import relativedelta as rd +from rich.progress import Progress from Cryptodome.Hash import MD4 from OpenSSL.SSL import SysCallError from bloodhound.ad.authentication import ADAuthentication @@ -47,6 +48,7 @@ from nxc.parsers.ldap_results import parse_result_attributes from nxc.helpers.ntlm_parser import parse_challenge from nxc.paths import CONFIG_PATH +from nxc.console import nxc_console ldap_error_status = { "1": "STATUS_NOT_SUPPORTED", @@ -1002,12 +1004,12 @@ def check_user(self): This checks user existence without triggering badPwdCount by analyzing KDC error responses. """ username = self.args.check_user.strip() - + self.logger.display(f"Checking username: {username}") - + kerberos_attacks = KerberosAttacks(self) result = kerberos_attacks.check_user_exists(username) - + if result is True: self.logger.highlight(f"[+] {username} - Valid username") elif result is False: @@ -1021,14 +1023,16 @@ def enum_users(self): This checks user existence without triggering badPwdCount by analyzing KDC error responses. """ usernames = [] - + # Parse input - can be usernames or files containing usernames for item in self.args.enum_users: if os.path.isfile(item): try: with open(item, encoding="utf-8") as f: usernames.extend(line.strip() for line in f if line.strip()) - self.logger.info(f"Loaded {len([line.strip() for line in open(item) if line.strip()])} usernames from file: {item}") + self.logger.info( + f"Loaded {len([line.strip() for line in open(item) if line.strip()])} usernames from file: {item}" + ) except Exception as e: self.logger.fail(f"Failed to read file '{item}': {e}") return @@ -1039,31 +1043,59 @@ def enum_users(self): self.logger.fail("No usernames provided for enumeration") return - self.logger.display(f"Starting Kerberos user enumeration with {len(usernames)} username(s)") - + self.logger.display( + f"Starting Kerberos user enumeration with {len(usernames)} username(s)" + ) + valid_users = [] invalid_users = [] errors = [] - + kerberos_attacks = KerberosAttacks(self) - - for username in usernames: - result = kerberos_attacks.check_user_exists(username) - - if result is True: - valid_users.append(username) - self.logger.highlight(f"[+] {username} - Valid username") - elif result is False: - invalid_users.append(username) - self.logger.verbose(f"[-] {username} - Invalid username") - else: - errors.append(username) - self.logger.fail(f"[!] {username} - Error during check") - + + # Use progress bar for large username lists (>100) + total = len(usernames) + if total > 100: + with Progress(console=nxc_console) as progress: + task = progress.add_task( + f"[cyan]Enumerating {total} {'username' if total == 1 else 'usernames'}", + total=total + ) + + for username in usernames: + result = kerberos_attacks.check_user_exists(username) + + if result is True: + valid_users.append(username) + self.logger.highlight(f"[+] {username} - Valid username") + elif result is False: + invalid_users.append(username) + # Invalid usernames are not logged (silent, like other enumeration methods) + else: + errors.append(username) + self.logger.fail(f"[!] {username} - Error during check") + + progress.update(task, advance=1) + else: + # For small lists, no progress bar needed + for username in usernames: + result = kerberos_attacks.check_user_exists(username) + + if result is True: + valid_users.append(username) + self.logger.highlight(f"[+] {username} - Valid username") + elif result is False: + invalid_users.append(username) + # Invalid usernames are not logged (silent, like other enumeration methods) + else: + errors.append(username) + self.logger.fail(f"[!] {username} - Error during check") + # Summary - self.logger.display("") - self.logger.success(f"Enumeration complete: {len(valid_users)} valid, {len(invalid_users)} invalid, {len(errors)} errors") - + self.logger.success( + f"Enumeration complete: {len(valid_users)} valid, {len(invalid_users)} invalid, {len(errors)} errors" + ) + if valid_users: self.logger.display(f"Valid usernames: {', '.join(valid_users)}") From be9cbd28338b3debd346d6bb85301efefc7b6fe7 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:25:26 +0100 Subject: [PATCH 03/26] feat: Implement threaded enumeration for Kerberos username checks --- nxc/helpers/misc.py | 148 ++++++++++++++++++++++++++++++++++++++++++ nxc/protocols/ldap.py | 77 +++++++++------------- 2 files changed, 180 insertions(+), 45 deletions(-) diff --git a/nxc/helpers/misc.py b/nxc/helpers/misc.py index 7f2d7a5f4a..fb64a24f9d 100755 --- a/nxc/helpers/misc.py +++ b/nxc/helpers/misc.py @@ -4,10 +4,158 @@ import re import inspect import os +import threading from termcolor import colored from ipaddress import ip_address from nxc.logger import nxc_logger from time import strftime, gmtime +from concurrent.futures import ThreadPoolExecutor, as_completed +from functools import wraps + + + +def threaded_enumeration(items_param="items", max_workers=10, progress_threshold=100, show_progress=True): + """ + Decorator to add multi-threading support to enumeration methods. + + This decorator transforms a sequential enumeration function into a concurrent one, + automatically handling threading, progress bars, and result aggregation. + + Args: + items_param (str): Name of the parameter containing the list of items to enumerate. + Default: "items" + max_workers (int): Maximum number of concurrent threads. Default: 10 + progress_threshold (int): Minimum number of items before showing progress bar. + Set to 0 to always show. Default: 100 + show_progress (bool): Whether to show progress bar. Default: True + + Usage: + @threaded_enumeration(items_param="usernames", max_workers=20, progress_threshold=50) + def enumerate_users(self, usernames): + '''Process a single username and return result''' + result = self.check_username(username) + return {"username": username, "valid": result} + + The decorated function should: + 1. Accept an iterable as a parameter (name specified by items_param) + 2. Process ONE item from that iterable + 3. Return a result dict or None + + The decorator will: + 1. Extract the items list from function parameters + 2. Call the function once per item in parallel threads + 3. Show progress bar if enabled and threshold met + 4. Return a list of all results (excluding None values) + + Returns: + list: Aggregated results from all thread executions (None values filtered out) + + Example: + @threaded_enumeration(items_param="users", max_workers=15) + def check_users(self, users): + # This function processes ONE user at a time + # Called automatically by the decorator for each user in the list + is_valid = self.kerberos_check(users) + if is_valid: + self.logger.highlight(f"Valid: {users}") + return {"user": users, "valid": True} + return None + + # Call like normal - the decorator handles threading + results = connection.check_users(["admin", "user1", "user2"]) + # results = [{"user": "admin", "valid": True}, ...] + """ + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Get the instance (self) if it's a method + instance = args[0] if args else None + + # Extract the items list from parameters + sig = inspect.signature(func) + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + + if items_param not in bound_args.arguments: + raise ValueError( + f"Parameter '{items_param}' not found in function {func.__name__}. " + f"Available parameters: {list(bound_args.arguments.keys())}" + ) + + items = bound_args.arguments[items_param] + + # Validate items is iterable + if not hasattr(items, "__iter__") or isinstance(items, (str, bytes)): + raise TypeError( + f"Parameter '{items_param}' must be an iterable (list, tuple, etc.), " + f"got {type(items).__name__}" + ) + + items_list = list(items) + total = len(items_list) + + if total == 0: + return [] + + results = [] + + # Determine if we should show progress + use_progress = show_progress and total > progress_threshold + + def process_item(item): + """Process a single item by calling the original function""" + # Create new args with just the single item + new_kwargs = bound_args.arguments.copy() + new_kwargs[items_param] = item + + # Remove 'self' from kwargs if present + new_kwargs.pop('self', None) + + # Call function with instance if it's a method + if instance is not None: + return func(instance, **new_kwargs) + else: + return func(**new_kwargs) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(process_item, item): item for item in items_list} + + if use_progress: + # Import here to avoid circular imports + from rich.progress import Progress + from nxc.console import nxc_console + + with Progress(console=nxc_console) as progress: + task = progress.add_task( + f"[cyan]Processing {total} {items_param}", + total=total + ) + + for future in as_completed(futures): + try: + result = future.result() + if result is not None: + results.append(result) + except Exception as e: + item = futures[future] + nxc_logger.error(f"Error processing {item}: {e}") + finally: + progress.update(task, advance=1) + else: + # No progress bar - just collect results + for future in as_completed(futures): + try: + result = future.result() + if result is not None: + results.append(result) + except Exception as e: + item = futures[future] + nxc_logger.error(f"Error processing {item}: {e}") + + return results + + return wrapper + return decorator def identify_target_file(target_file): diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 13ae27d247..efbdfed9e3 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -13,7 +13,6 @@ from dns import resolver from dateutil.relativedelta import relativedelta as rd -from rich.progress import Progress from Cryptodome.Hash import MD4 from OpenSSL.SSL import SysCallError from bloodhound.ad.authentication import ADAuthentication @@ -40,7 +39,7 @@ from nxc.config import process_secret, host_info_colors from nxc.connection import connection from nxc.helpers.bloodhound import add_user_bh -from nxc.helpers.misc import get_bloodhound_info, convert, d2b +from nxc.helpers.misc import get_bloodhound_info, convert, d2b, threaded_enumeration from nxc.logger import NXCAdapter, nxc_logger from nxc.protocols.ldap.bloodhound import BloodHound from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB @@ -48,7 +47,6 @@ from nxc.parsers.ldap_results import parse_result_attributes from nxc.helpers.ntlm_parser import parse_challenge from nxc.paths import CONFIG_PATH -from nxc.console import nxc_console ldap_error_status = { "1": "STATUS_NOT_SUPPORTED", @@ -1047,49 +1045,13 @@ def enum_users(self): f"Starting Kerberos user enumeration with {len(usernames)} username(s)" ) - valid_users = [] - invalid_users = [] - errors = [] + # Use the threaded enumeration helper + results = self._check_username_batch(usernames) - kerberos_attacks = KerberosAttacks(self) - - # Use progress bar for large username lists (>100) - total = len(usernames) - if total > 100: - with Progress(console=nxc_console) as progress: - task = progress.add_task( - f"[cyan]Enumerating {total} {'username' if total == 1 else 'usernames'}", - total=total - ) - - for username in usernames: - result = kerberos_attacks.check_user_exists(username) - - if result is True: - valid_users.append(username) - self.logger.highlight(f"[+] {username} - Valid username") - elif result is False: - invalid_users.append(username) - # Invalid usernames are not logged (silent, like other enumeration methods) - else: - errors.append(username) - self.logger.fail(f"[!] {username} - Error during check") - - progress.update(task, advance=1) - else: - # For small lists, no progress bar needed - for username in usernames: - result = kerberos_attacks.check_user_exists(username) - - if result is True: - valid_users.append(username) - self.logger.highlight(f"[+] {username} - Valid username") - elif result is False: - invalid_users.append(username) - # Invalid usernames are not logged (silent, like other enumeration methods) - else: - errors.append(username) - self.logger.fail(f"[!] {username} - Error during check") + # Aggregate results + valid_users = [r["username"] for r in results if r["status"] == "valid"] + invalid_users = [r["username"] for r in results if r["status"] == "invalid"] + errors = [r["username"] for r in results if r["status"] == "error"] # Summary self.logger.success( @@ -1099,6 +1061,31 @@ def enum_users(self): if valid_users: self.logger.display(f"Valid usernames: {', '.join(valid_users)}") + @threaded_enumeration(items_param="usernames", max_workers=10, progress_threshold=100) + def _check_username_batch(self, usernames): + """ + Check a single username via Kerberos AS-REQ. + This method is decorated to run concurrently for multiple usernames. + + Args: + usernames: Single username to check (despite plural name, decorator handles iteration) + + Returns: + dict: {"username": str, "status": "valid"|"invalid"|"error"} + """ + kerberos_attacks = KerberosAttacks(self) + result = kerberos_attacks.check_user_exists(usernames) + + if result is True: + self.logger.highlight(f"[+] {usernames} - Valid username") + return {"username": usernames, "status": "valid"} + elif result is False: + # Invalid usernames are not logged (silent, like other enumeration methods) + return {"username": usernames, "status": "invalid"} + else: + self.logger.fail(f"[!] {usernames} - Error during check") + return {"username": usernames, "status": "error"} + def kerberoasting(self): if self.args.no_preauth_targets: usernames = [] From d2c2e3a9380724dc2a108360e09ca780d86948a7 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:29:53 +0100 Subject: [PATCH 04/26] feat: Update threaded_enumeration to allow dynamic max_workers from instance args --- nxc/helpers/misc.py | 25 ++++++++++++++++++++++--- nxc/protocols/ldap.py | 3 ++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/nxc/helpers/misc.py b/nxc/helpers/misc.py index fb64a24f9d..a08d5b204a 100755 --- a/nxc/helpers/misc.py +++ b/nxc/helpers/misc.py @@ -14,7 +14,7 @@ -def threaded_enumeration(items_param="items", max_workers=10, progress_threshold=100, show_progress=True): +def threaded_enumeration(items_param="items", max_workers=None, progress_threshold=100, show_progress=True): """ Decorator to add multi-threading support to enumeration methods. @@ -24,18 +24,27 @@ def threaded_enumeration(items_param="items", max_workers=10, progress_threshold Args: items_param (str): Name of the parameter containing the list of items to enumerate. Default: "items" - max_workers (int): Maximum number of concurrent threads. Default: 10 + max_workers (int): Maximum number of concurrent threads. If None, uses self.args.threads + from the instance, or defaults to 10 if not available. Default: None progress_threshold (int): Minimum number of items before showing progress bar. Set to 0 to always show. Default: 100 show_progress (bool): Whether to show progress bar. Default: True Usage: + # Use explicit max_workers @threaded_enumeration(items_param="usernames", max_workers=20, progress_threshold=50) def enumerate_users(self, usernames): '''Process a single username and return result''' result = self.check_username(username) return {"username": username, "valid": result} + # Or use None to automatically use self.args.threads + @threaded_enumeration(items_param="usernames", progress_threshold=50) + def enumerate_users(self, usernames): + '''Process a single username and return result''' + result = self.check_username(username) + return {"username": username, "valid": result} + The decorated function should: 1. Accept an iterable as a parameter (name specified by items_param) 2. Process ONE item from that iterable @@ -99,6 +108,16 @@ def wrapper(*args, **kwargs): results = [] + # Determine max_workers: use decorator parameter, then self.args.threads, then default 10 + workers = max_workers + if workers is None: + if instance and hasattr(instance, 'args') and hasattr(instance.args, 'threads'): + workers = instance.args.threads + nxc_logger.debug(f"Using {workers} threads from --threads argument") + else: + workers = 10 + nxc_logger.debug(f"Using default {workers} threads") + # Determine if we should show progress use_progress = show_progress and total > progress_threshold @@ -117,7 +136,7 @@ def process_item(item): else: return func(**new_kwargs) - with ThreadPoolExecutor(max_workers=max_workers) as executor: + with ThreadPoolExecutor(max_workers=workers) as executor: futures = {executor.submit(process_item, item): item for item in items_list} if use_progress: diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index efbdfed9e3..4a169329e5 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -1061,11 +1061,12 @@ def enum_users(self): if valid_users: self.logger.display(f"Valid usernames: {', '.join(valid_users)}") - @threaded_enumeration(items_param="usernames", max_workers=10, progress_threshold=100) + @threaded_enumeration(items_param="usernames", progress_threshold=100) def _check_username_batch(self, usernames): """ Check a single username via Kerberos AS-REQ. This method is decorated to run concurrently for multiple usernames. + The number of threads is automatically determined from self.args.threads (--threads CLI argument). Args: usernames: Single username to check (despite plural name, decorator handles iteration) From 3ab1c7b1b055cbb4e5dc5cc3826e8440a0817982 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:30:15 +0100 Subject: [PATCH 05/26] feat: Implement Kerberos protocol with user enumeration and database support --- nxc/protocols/kerberos.py | 460 ++++++++++++++++++++++ nxc/protocols/kerberos/__init__.py | 0 nxc/protocols/kerberos/database.py | 37 ++ nxc/protocols/kerberos/kerberosattacks.py | 168 ++++++++ nxc/protocols/kerberos/proto_args.py | 51 +++ nxc/protocols/ldap/proto_args.py | 4 - 6 files changed, 716 insertions(+), 4 deletions(-) create mode 100644 nxc/protocols/kerberos.py create mode 100644 nxc/protocols/kerberos/__init__.py create mode 100644 nxc/protocols/kerberos/database.py create mode 100644 nxc/protocols/kerberos/kerberosattacks.py create mode 100644 nxc/protocols/kerberos/proto_args.py diff --git a/nxc/protocols/kerberos.py b/nxc/protocols/kerberos.py new file mode 100644 index 0000000000..1c134cf289 --- /dev/null +++ b/nxc/protocols/kerberos.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import socket +import time +import binascii +from impacket.krb5 import constants +from impacket.krb5.asn1 import AS_REQ, AS_REP, TGS_REQ, TGS_REP, KRB_ERROR +from impacket.krb5.kerberosv5 import sendReceive, KerberosError, getKerberosTGT +from impacket.krb5.types import Principal, KerberosException +from impacket.krb5.ccache import CCache +from pyasn1.codec.der import decoder + +from nxc.connection import connection +from nxc.config import host_info_colors, process_secret +from nxc.logger import NXCAdapter +from nxc.helpers.misc import threaded_enumeration +from nxc.helpers.logger import highlight +from nxc.protocols.kerberos.kerberosattacks import KerberosUserEnum + + +class kerberos(connection): + """ + Kerberos protocol implementation for NetExec. + + This protocol provides Kerberos-specific enumeration and attack capabilities + without requiring LDAP or SMB connections. + """ + + def __init__(self, args, db, host): + self.domain = None + self.kdcHost = None + self.hash = None + self.lmhash = "" + self.nthash = "" + self.aesKey = "" + self.port = 88 + + connection.__init__(self, args, db, host) + + def proto_logger(self): + """Initialize the protocol-specific logger""" + self.logger = NXCAdapter( + extra={ + "protocol": "KRB5", + "host": self.host, + "port": self.port, + "hostname": self.hostname if hasattr(self, 'hostname') else self.host, + } + ) + + def create_conn_obj(self): + """ + Create connection object (minimal for Kerberos - just validate KDC is reachable) + """ + try: + # Test if the KDC port is open + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(self.args.timeout if self.args.timeout else 5) + result = sock.connect_ex((self.host, self.port)) + sock.close() + + if result == 0: + self.logger.debug(f"Kerberos port {self.port} is open on {self.host}") + return True + else: + self.logger.fail(f"Kerberos port {self.port} is closed on {self.host}") + return False + + except socket.timeout: + self.logger.fail(f"Connection timeout to {self.host}:{self.port}") + return False + except Exception as e: + self.logger.fail(f"Error connecting to {self.host}:{self.port} - {e}") + return False + + def enum_host_info(self): + """ + Enumerate basic host information + """ + # Set domain from args + if self.args.domain: + self.domain = self.args.domain.upper() + + # Set KDC host (can be different from target) + if self.args.kdcHost: + self.kdcHost = self.args.kdcHost + else: + self.kdcHost = self.host + + # Try to resolve hostname + try: + self.hostname = socket.gethostbyaddr(self.host)[0] + except Exception: + self.hostname = self.host + + self.logger.debug(f"Domain: {self.domain}, KDC: {self.kdcHost}, Hostname: {self.hostname}") + + # Add to database + try: + self.db.add_host( + self.host, + self.hostname, + self.domain, + "Kerberos" + ) + except Exception as e: + self.logger.debug(f"Error adding host to database: {e}") + + def print_host_info(self): + """Print host information""" + self.logger.display( + f"Kerberos KDC (domain:{self.domain}) (hostname:{self.hostname})" + ) + + def login(self): + """ + Override the default login method to handle Kerberos-specific logic. + + For Kerberos protocol: + - If usernames provided WITHOUT passwords: batch enumerate valid usernames + - If usernames AND passwords provided: authenticate normally (call parent login()) + """ + # Check if we have usernames but no passwords - enumeration mode + if self.args.username and not self.args.password: + self.logger.debug("Kerberos enumeration mode: usernames without passwords") + + # Parse all usernames from args (can be files or direct usernames) + usernames = [] + for user_item in self.args.username: + user_item = user_item.strip() + try: + # Try to open as file + with open(user_item, 'r') as f: + file_users = [line.strip() for line in f if line.strip()] + usernames.extend(file_users) + self.logger.info(f"Loaded {len(file_users)} usernames from {user_item}") + except FileNotFoundError: + # Not a file, treat as username + usernames.append(user_item) + except Exception as e: + self.logger.debug(f"Error reading file {user_item}: {e}") + usernames.append(user_item) + + # Remove duplicates + usernames = list(set(usernames)) + + if not usernames: + self.logger.fail("No valid usernames to enumerate") + return False + + # Perform enumeration based on count + if len(usernames) == 1: + return self._check_single_user(usernames[0]) + else: + return self._enum_multiple_users(usernames) + else: + # Normal authentication with credentials - use parent's login method + return super().login() + + def plaintext_login(self, domain, username, password): + """ + Authenticate to Kerberos KDC using username and password. + + This method attempts to get a TGT to verify credentials. + """ + self.username = username + self.password = password + self.domain = domain # Try to authenticate with password + try: + self.logger.debug(f"Attempting Kerberos authentication for {domain}\\{username}") + + userName = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) + + tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT( + clientName=userName, + password=password, + domain=domain.upper(), + lmhash=binascii.unhexlify(self.lmhash) if self.lmhash else b"", + nthash=binascii.unhexlify(self.nthash) if self.nthash else b"", + aesKey=self.aesKey if self.aesKey else "", + kdcHost=self.kdcHost + ) + + self.logger.success(f"{domain}\\{username}:{process_secret(password)}") + + # Add credential to database + self.db.add_credential("plaintext", domain, username, password) + + return True + + except KerberosException as e: + error_msg = str(e) + + # Parse common Kerberos errors + if "KDC_ERR_PREAUTH_FAILED" in error_msg: + self.logger.fail(f"{domain}\\{username}:{process_secret(password)} (invalid credentials)") + elif "KDC_ERR_CLIENT_REVOKED" in error_msg: + self.logger.fail(f"{domain}\\{username}:{process_secret(password)} (account disabled)") + elif "KDC_ERR_C_PRINCIPAL_UNKNOWN" in error_msg: + self.logger.fail(f"{domain}\\{username}:{process_secret(password)} (user does not exist)") + else: + self.logger.fail(f"{domain}\\{username}:{process_secret(password)} ({error_msg})") + + return False + + except Exception as e: + self.logger.fail(f"{domain}\\{username}:{process_secret(password)} (error: {e})") + return False + + def hash_login(self, domain, username, ntlm_hash): + """ + Authenticate to Kerberos KDC using username and NTLM hash. + """ + self.username = username + self.domain = domain + + # Parse NTLM hash + lmhash = "" + nthash = "" + + if ":" in ntlm_hash: + lmhash, nthash = ntlm_hash.split(":") + else: + nthash = ntlm_hash + + self.lmhash = lmhash + self.nthash = nthash + self.hash = ntlm_hash + + try: + self.logger.debug(f"Attempting Kerberos authentication for {domain}\\{username} with NTLM hash") + + userName = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) + + tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT( + clientName=userName, + password="", + domain=domain.upper(), + lmhash=binascii.unhexlify(lmhash) if lmhash else b"", + nthash=binascii.unhexlify(nthash) if nthash else b"", + aesKey="", + kdcHost=self.kdcHost + ) + + self.logger.success(f"{domain}\\{username}:{process_secret(ntlm_hash)}") + + # Add credential to database + self.db.add_credential("hash", domain, username, ntlm_hash) + + return True + + except KerberosException as e: + error_msg = str(e) + + if "KDC_ERR_PREAUTH_FAILED" in error_msg: + self.logger.fail(f"{domain}\\{username}:{process_secret(ntlm_hash)} (invalid hash)") + elif "KDC_ERR_CLIENT_REVOKED" in error_msg: + self.logger.fail(f"{domain}\\{username}:{process_secret(ntlm_hash)} (account disabled)") + elif "KDC_ERR_C_PRINCIPAL_UNKNOWN" in error_msg: + self.logger.fail(f"{domain}\\{username}:{process_secret(ntlm_hash)} (user does not exist)") + else: + self.logger.fail(f"{domain}\\{username}:{process_secret(ntlm_hash)} ({error_msg})") + + return False + + except Exception as e: + self.logger.fail(f"{domain}\\{username}:{process_secret(ntlm_hash)} (error: {e})") + return False + + def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False): + """ + Authenticate using Kerberos with various credential types. + """ + self.username = username + self.password = password + self.domain = domain + self.kdcHost = kdcHost if kdcHost else self.kdcHost + self.aesKey = aesKey + + # Parse NTLM hash if provided + lmhash = "" + nthash = "" + + if ntlm_hash: + if ":" in ntlm_hash: + lmhash, nthash = ntlm_hash.split(":") + else: + nthash = ntlm_hash + + self.lmhash = lmhash + self.nthash = nthash + self.hash = ntlm_hash + + try: + self.logger.debug(f"Attempting Kerberos authentication for {domain}\\{username}") + + userName = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) + + tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT( + clientName=userName, + password=password, + domain=domain.upper(), + lmhash=binascii.unhexlify(lmhash) if lmhash else b"", + nthash=binascii.unhexlify(nthash) if nthash else b"", + aesKey=aesKey if aesKey else "", + kdcHost=self.kdcHost + ) + + # Determine what credential was used + if aesKey: + cred_type = "aesKey" + cred_value = aesKey + elif nthash: + cred_type = "hash" + cred_value = ntlm_hash + else: + cred_type = "plaintext" + cred_value = password + + self.logger.success(f"{domain}\\{username}:{process_secret(cred_value)}") + + # Add credential to database + self.db.add_credential(cred_type, domain, username, cred_value) + + return True + + except KerberosException as e: + error_msg = str(e) + cred_value = aesKey or ntlm_hash or password + + if "KDC_ERR_PREAUTH_FAILED" in error_msg: + self.logger.fail(f"{domain}\\{username}:{process_secret(cred_value)} (invalid credentials)") + elif "KDC_ERR_CLIENT_REVOKED" in error_msg: + self.logger.fail(f"{domain}\\{username}:{process_secret(cred_value)} (account disabled)") + elif "KDC_ERR_C_PRINCIPAL_UNKNOWN" in error_msg: + self.logger.fail(f"{domain}\\{username}:{process_secret(cred_value)} (user does not exist)") + else: + self.logger.fail(f"{domain}\\{username}:{process_secret(cred_value)} ({error_msg})") + + return False + + except Exception as e: + cred_value = aesKey or ntlm_hash or password + self.logger.fail(f"{domain}\\{username}:{process_secret(cred_value)} (error: {e})") + return False + + def _check_single_user(self, username): + """ + Check if a single username is valid via Kerberos AS-REQ request. + This checks user existence without triggering badPwdCount. + """ + kerberos_enum = KerberosUserEnum( + domain=self.domain, + kdcHost=self.kdcHost, + timeout=self.args.timeout if self.args.timeout else 10 + ) + + result = kerberos_enum.check_user_exists(username) + + if result is True: + self.logger.success(f"{self.domain}\\{username}") + # Add to database + try: + self.db.add_host( + self.host, + self.hostname, + self.domain, + "Kerberos" + ) + except Exception: + pass + return True + elif result == "ACCOUNT_DISABLED": + self.logger.highlight(f"{self.domain}\\{username} (disabled)") + return True + elif result is False: + # Only show invalid usernames in debug mode during enumeration + self.logger.debug(f"{self.domain}\\{username} (invalid)") + return False + else: + self.logger.debug(f"{self.domain}\\{username} (error: {result})") + return False + + def _enum_multiple_users(self, usernames): + """ + Enumerate valid domain usernames via Kerberos AS-REQ requests. + This checks user existence without triggering badPwdCount. + """ + self.logger.display( + f"Starting Kerberos user enumeration with {len(usernames)} username(s)" + ) + + # Use the threaded enumeration helper + results = self._check_username_batch(usernames) + + # Aggregate results + valid_users = [r["username"] for r in results if r["status"] == "valid"] + invalid_users = [r["username"] for r in results if r["status"] == "invalid"] + errors = [r["username"] for r in results if r["status"] == "error"] + + # Summary + self.logger.success( + f"Enumeration complete: {len(valid_users)} valid, {len(invalid_users)} invalid, {len(errors)} errors" + ) + + if valid_users: + self.logger.display(f"Valid usernames: {', '.join(valid_users)}") + + # Save to file if requested + if self.args.log: + output_file = f"{self.args.log}_valid_users.txt" + try: + with open(output_file, 'w') as f: + f.write('\n'.join(valid_users)) + self.logger.success(f"Valid usernames saved to {output_file}") + except Exception as e: + self.logger.fail(f"Error saving valid usernames: {e}") + + return len(valid_users) > 0 + + @threaded_enumeration(items_param="usernames", progress_threshold=100) + def _check_username_batch(self, usernames): + """ + Check a single username via Kerberos AS-REQ. + This method is decorated to run concurrently for multiple usernames. + The number of threads is automatically determined from self.args.threads (--threads CLI argument). + + Args: + usernames: Single username to check (despite plural name, decorator handles iteration) + + Returns: + dict: {"username": str, "status": "valid"|"invalid"|"error"} + """ + kerberos_enum = KerberosUserEnum( + domain=self.domain, + kdcHost=self.kdcHost, + timeout=self.args.timeout if self.args.timeout else 10 + ) + + # Add delay if requested (for stealth/rate limiting) + if hasattr(self.args, 'delay') and self.args.delay > 0: + time.sleep(self.args.delay) + + result = kerberos_enum.check_user_exists(usernames) + + if result is True: + self.logger.highlight(f"[+] {usernames}") + return {"username": usernames, "status": "valid"} + elif result == "ACCOUNT_DISABLED": + self.logger.highlight(f"[+] {usernames} (disabled)") + return {"username": usernames, "status": "valid", "disabled": True} + elif result is False: + self.logger.debug(f"[-] {usernames}") + return {"username": usernames, "status": "invalid"} + else: + self.logger.error(f"[!] {usernames}: {result}") + return {"username": usernames, "status": "error", "error": result} + + diff --git a/nxc/protocols/kerberos/__init__.py b/nxc/protocols/kerberos/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nxc/protocols/kerberos/database.py b/nxc/protocols/kerberos/database.py new file mode 100644 index 0000000000..9bcd0333d2 --- /dev/null +++ b/nxc/protocols/kerberos/database.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Database module for Kerberos protocol + +Currently minimal - just provides compatibility with NetExec's database structure. +Can be extended in the future to store enumeration results, discovered users, etc. +""" + + +class database: + """Kerberos protocol database handler""" + + def __init__(self, db_engine): + self.db_engine = db_engine + + @staticmethod + def db_schema(db_conn): + """ + Define database schema for Kerberos protocol + + Currently minimal - can be extended to store: + - Enumerated users + - AS-REP roastable accounts + - Timing information for stealth tracking + - etc. + """ + db_conn.execute("""CREATE TABLE IF NOT EXISTS kerberos_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER, + domain TEXT, + username TEXT, + status TEXT, + timestamp TEXT, + FOREIGN KEY(host_id) REFERENCES hosts(id) + )""") diff --git a/nxc/protocols/kerberos/kerberosattacks.py b/nxc/protocols/kerberos/kerberosattacks.py new file mode 100644 index 0000000000..72f8a5021b --- /dev/null +++ b/nxc/protocols/kerberos/kerberosattacks.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Kerberos Attack & Enumeration Module + +This module provides Kerberos-specific attack and enumeration capabilities, +particularly focusing on user enumeration without incrementing badPwdCount. +""" + +import socket +from binascii import hexlify +from datetime import datetime +try: + from datetime import UTC + utc_failed = False +except ImportError: + utc_failed = True + +from impacket.krb5 import constants +from impacket.krb5.asn1 import AS_REQ, AS_REP, KRB_ERROR, seq_set, seq_set_iter, KERB_PA_PAC_REQUEST +from impacket.krb5.kerberosv5 import sendReceive, KerberosError +from impacket.krb5.types import Principal, KerberosTime +from pyasn1.codec.der import decoder, encoder +from pyasn1.type.univ import noValue + +from nxc.logger import nxc_logger + + +class KerberosUserEnum: + """ + Kerberos User Enumeration Class + + Provides methods to enumerate valid Active Directory usernames via Kerberos + AS-REQ requests without triggering badPwdCount increments. + """ + + def __init__(self, domain, kdcHost, timeout=10): + """ + Initialize Kerberos User Enumeration + + Args: + domain (str): The target Active Directory domain (e.g., 'CORP.LOCAL') + kdcHost (str): IP address or FQDN of the KDC (Domain Controller) + timeout (int): Socket timeout in seconds + """ + self.domain = domain.upper() + self.kdcHost = kdcHost + self.timeout = timeout + + def check_user_exists(self, username: str) -> bool: + """ + Check if a username exists in Active Directory via Kerberos AS-REQ. + + This method sends a Kerberos AS-REQ (Authentication Service Request) with + no preauthentication data. The KDC's response reveals whether the user exists: + + - KDC_ERR_PREAUTH_REQUIRED (KDC_ERR_CODE 25): User exists (preauth required) + - KDC_ERR_C_PRINCIPAL_UNKNOWN (KDC_ERR_CODE 6): User does not exist + - KDC_ERR_CLIENT_REVOKED: User account is disabled + - Other errors: Various account/policy issues + + This method does NOT increment badPwdCount since no password is provided. + + Args: + username (str): Username to check (without domain) + + Returns: + bool or str: + - True if user exists and is valid + - False if user does not exist + - Error string if another error occurred + """ + try: + # Build the principal name + client_principal = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) + + # Build AS-REQ + as_req = AS_REQ() + + # Set domain + as_req['pvno'] = 5 + as_req['msg-type'] = int(constants.ApplicationTagNumbers.AS_REQ.value) + + # Request body + req_body = seq_set(as_req, 'req-body') + + # KDC Options - request forwardable and renewable tickets + opts = list() + opts.append(constants.KDCOptions.forwardable.value) + opts.append(constants.KDCOptions.renewable.value) + opts.append(constants.KDCOptions.renewable_ok.value) + req_body['kdc-options'] = constants.encodeFlags(opts) + + # Set client principal + seq_set(req_body, 'cname', client_principal.components_to_asn1) + req_body['realm'] = self.domain + + # Set server principal (krbtgt) + server_principal = Principal( + f'krbtgt/{self.domain}', + type=constants.PrincipalNameType.NT_PRINCIPAL.value + ) + seq_set(req_body, 'sname', server_principal.components_to_asn1) + + # Set time fields + if utc_failed: + now = datetime.utcnow() + else: + now = datetime.now(UTC).replace(tzinfo=None) + req_body['till'] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) + req_body['rtime'] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) + req_body['nonce'] = 123456789 # Can be any value + + # Set encryption types - prefer AES + supported_ciphers = ( + constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value, + constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value, + constants.EncryptionTypes.rc4_hmac.value, + ) + seq_set_iter(req_body, 'etype', supported_ciphers) + + # No preauthentication data (this is key for enumeration) + # We deliberately don't include PA-DATA to trigger preauth required response + + # Encode and send the request + message = encoder.encode(as_req) + + try: + response = sendReceive(message, self.domain, self.kdcHost) + except KerberosError as e: + # Analyze the error code to determine user status + error_code = e.getErrorCode() + + if error_code == constants.ErrorCodes.KDC_ERR_PREAUTH_REQUIRED.value: + # User exists! (KDC requires preauthentication) + return True + + elif error_code == constants.ErrorCodes.KDC_ERR_C_PRINCIPAL_UNKNOWN.value: + # User does not exist + return False + + elif error_code == constants.ErrorCodes.KDC_ERR_CLIENT_REVOKED.value: + # User exists but account is disabled + nxc_logger.debug(f"User {username} exists but account is disabled") + return "ACCOUNT_DISABLED" + + elif error_code == constants.ErrorCodes.KDC_ERR_WRONG_REALM.value: + return "WRONG_REALM" + + else: + # Other Kerberos error + error_msg = constants.ErrorCodes(error_code).name if hasattr(constants.ErrorCodes, '_value2member_map_') else str(error_code) + nxc_logger.debug(f"Kerberos error for {username}: {error_msg}") + return f"KRB_ERROR_{error_code}" + + # If we get an AS-REP without error, user exists (very rare without preauth) + return True + + except socket.timeout: + return "TIMEOUT" + except socket.error as e: + return f"SOCKET_ERROR: {e}" + except Exception as e: + nxc_logger.debug(f"Unexpected error checking {username}: {e}") + return f"ERROR: {e}" + + diff --git a/nxc/protocols/kerberos/proto_args.py b/nxc/protocols/kerberos/proto_args.py new file mode 100644 index 0000000000..6453f5b44f --- /dev/null +++ b/nxc/protocols/kerberos/proto_args.py @@ -0,0 +1,51 @@ +from nxc.helpers.args import DisplayDefaultsNotNone + + +def proto_args(parser, parents): + """Define CLI arguments for the Kerberos protocol""" + kerberos_parser = parser.add_parser( + "kerberos", + help="Kerberos user enumeration (no badPwdCount increment)", + parents=parents, + formatter_class=DisplayDefaultsNotNone + ) + + # Basic connection arguments + kerberos_parser.add_argument( + "--port", + type=int, + default=88, + help="Kerberos port (default: 88)" + ) + + kerberos_parser.add_argument( + "-d", + metavar="DOMAIN", + dest="domain", + type=str, + required=True, + help="Domain to enumerate" + ) + + kerberos_parser.add_argument( + "--dc-ip", + dest="kdcHost", + metavar="DC_IP", + help="IP address or FQDN of the Domain Controller (KDC)" + ) + + # Performance tuning + perf_group = kerberos_parser.add_argument_group( + "Performance", + "Options to tune enumeration performance" + ) + + perf_group.add_argument( + "--delay", + type=float, + default=0, + metavar="SECONDS", + help="Delay between requests in seconds (for stealth or rate limiting)" + ) + + return parser diff --git a/nxc/protocols/ldap/proto_args.py b/nxc/protocols/ldap/proto_args.py index 327409bfbe..51f4e01847 100644 --- a/nxc/protocols/ldap/proto_args.py +++ b/nxc/protocols/ldap/proto_args.py @@ -16,10 +16,6 @@ def proto_args(parser, parents): kerberoasting_arg = egroup.add_argument("--kerberoasting", "--kerberoast", help="Output TGS ticket to crack with hashcat to file") kerberoast_users_arg = egroup.add_argument("--kerberoast-account", nargs="+", dest="kerberoast_account", action=get_conditional_action(_StoreAction), make_required=[], help="Target specific accounts for kerberoasting (sAMAccountNames or file containing sAMAccountNames)") egroup.add_argument("--no-preauth-targets", nargs=1, dest="no_preauth_targets", help="Targeted kerberoastable users") - - kgroup = ldap_parser.add_argument_group("Kerberos User Enumeration", "Enumerate valid usernames via Kerberos (no badPwdCount increment)") - kgroup.add_argument("--check-user", dest="check_user", help="Check if a single username is valid via Kerberos") - kgroup.add_argument("--enum-users", nargs="+", dest="enum_users", help="Enumerate multiple valid usernames via Kerberos (usernames or file containing usernames)") # Make kerberoast-users require kerberoasting kerberoast_users_arg.make_required = [kerberoasting_arg] From bf02ad4c2467c9818e06980d2d14083dc4563f9e Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:53:21 +0100 Subject: [PATCH 06/26] refactor: Remove user checking and enumeration methods from ldap class --- nxc/protocols/ldap.py | 93 +------------------------------------------ 1 file changed, 1 insertion(+), 92 deletions(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 4a169329e5..f06f52a71c 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -39,7 +39,7 @@ from nxc.config import process_secret, host_info_colors from nxc.connection import connection from nxc.helpers.bloodhound import add_user_bh -from nxc.helpers.misc import get_bloodhound_info, convert, d2b, threaded_enumeration +from nxc.helpers.misc import get_bloodhound_info, convert, d2b from nxc.logger import NXCAdapter, nxc_logger from nxc.protocols.ldap.bloodhound import BloodHound from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB @@ -996,97 +996,6 @@ def asreproast(self): with open(self.args.asreproast, "a+") as hash_asreproast: hash_asreproast.write(f"{hash_TGT}\n") - def check_user(self): - """ - Check if a single username is valid via Kerberos AS-REQ request. - This checks user existence without triggering badPwdCount by analyzing KDC error responses. - """ - username = self.args.check_user.strip() - - self.logger.display(f"Checking username: {username}") - - kerberos_attacks = KerberosAttacks(self) - result = kerberos_attacks.check_user_exists(username) - - if result is True: - self.logger.highlight(f"[+] {username} - Valid username") - elif result is False: - self.logger.fail(f"[-] {username} - Invalid username (does not exist)") - else: - self.logger.fail(f"[!] {username} - Error during check") - - def enum_users(self): - """ - Enumerate valid domain usernames via Kerberos AS-REQ requests. - This checks user existence without triggering badPwdCount by analyzing KDC error responses. - """ - usernames = [] - - # Parse input - can be usernames or files containing usernames - for item in self.args.enum_users: - if os.path.isfile(item): - try: - with open(item, encoding="utf-8") as f: - usernames.extend(line.strip() for line in f if line.strip()) - self.logger.info( - f"Loaded {len([line.strip() for line in open(item) if line.strip()])} usernames from file: {item}" - ) - except Exception as e: - self.logger.fail(f"Failed to read file '{item}': {e}") - return - else: - usernames.append(item.strip()) - - if not usernames: - self.logger.fail("No usernames provided for enumeration") - return - - self.logger.display( - f"Starting Kerberos user enumeration with {len(usernames)} username(s)" - ) - - # Use the threaded enumeration helper - results = self._check_username_batch(usernames) - - # Aggregate results - valid_users = [r["username"] for r in results if r["status"] == "valid"] - invalid_users = [r["username"] for r in results if r["status"] == "invalid"] - errors = [r["username"] for r in results if r["status"] == "error"] - - # Summary - self.logger.success( - f"Enumeration complete: {len(valid_users)} valid, {len(invalid_users)} invalid, {len(errors)} errors" - ) - - if valid_users: - self.logger.display(f"Valid usernames: {', '.join(valid_users)}") - - @threaded_enumeration(items_param="usernames", progress_threshold=100) - def _check_username_batch(self, usernames): - """ - Check a single username via Kerberos AS-REQ. - This method is decorated to run concurrently for multiple usernames. - The number of threads is automatically determined from self.args.threads (--threads CLI argument). - - Args: - usernames: Single username to check (despite plural name, decorator handles iteration) - - Returns: - dict: {"username": str, "status": "valid"|"invalid"|"error"} - """ - kerberos_attacks = KerberosAttacks(self) - result = kerberos_attacks.check_user_exists(usernames) - - if result is True: - self.logger.highlight(f"[+] {usernames} - Valid username") - return {"username": usernames, "status": "valid"} - elif result is False: - # Invalid usernames are not logged (silent, like other enumeration methods) - return {"username": usernames, "status": "invalid"} - else: - self.logger.fail(f"[!] {usernames} - Error during check") - return {"username": usernames, "status": "error"} - def kerberoasting(self): if self.args.no_preauth_targets: usernames = [] From 7d9733a17defe63eccb36c774b3d893d491ca804 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:56:26 +0100 Subject: [PATCH 07/26] refactor: Remove performance tuning options from Kerberos argument parser --- nxc/protocols/kerberos/proto_args.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/nxc/protocols/kerberos/proto_args.py b/nxc/protocols/kerberos/proto_args.py index 6453f5b44f..83bdde42f5 100644 --- a/nxc/protocols/kerberos/proto_args.py +++ b/nxc/protocols/kerberos/proto_args.py @@ -34,18 +34,4 @@ def proto_args(parser, parents): help="IP address or FQDN of the Domain Controller (KDC)" ) - # Performance tuning - perf_group = kerberos_parser.add_argument_group( - "Performance", - "Options to tune enumeration performance" - ) - - perf_group.add_argument( - "--delay", - type=float, - default=0, - metavar="SECONDS", - help="Delay between requests in seconds (for stealth or rate limiting)" - ) - return parser From 6febdc358ed23bc326d398be54f4e831f7073b2a Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:56:31 +0100 Subject: [PATCH 08/26] refactor: Clean up imports and simplify datetime handling in KerberosUserEnum --- nxc/protocols/kerberos/kerberosattacks.py | 32 +++++------------------ 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/nxc/protocols/kerberos/kerberosattacks.py b/nxc/protocols/kerberos/kerberosattacks.py index 72f8a5021b..1ab17ee31e 100644 --- a/nxc/protocols/kerberos/kerberosattacks.py +++ b/nxc/protocols/kerberos/kerberosattacks.py @@ -1,28 +1,13 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -Kerberos Attack & Enumeration Module - -This module provides Kerberos-specific attack and enumeration capabilities, -particularly focusing on user enumeration without incrementing badPwdCount. -""" - +# Standard library imports import socket -from binascii import hexlify -from datetime import datetime -try: - from datetime import UTC - utc_failed = False -except ImportError: - utc_failed = True +from datetime import datetime, timezone +# External library imports from impacket.krb5 import constants -from impacket.krb5.asn1 import AS_REQ, AS_REP, KRB_ERROR, seq_set, seq_set_iter, KERB_PA_PAC_REQUEST +from impacket.krb5.asn1 import AS_REQ, seq_set, seq_set_iter from impacket.krb5.kerberosv5 import sendReceive, KerberosError from impacket.krb5.types import Principal, KerberosTime -from pyasn1.codec.der import decoder, encoder -from pyasn1.type.univ import noValue +from pyasn1.codec.der import encoder from nxc.logger import nxc_logger @@ -103,11 +88,8 @@ def check_user_exists(self, username: str) -> bool: ) seq_set(req_body, 'sname', server_principal.components_to_asn1) - # Set time fields - if utc_failed: - now = datetime.utcnow() - else: - now = datetime.now(UTC).replace(tzinfo=None) + now = datetime.now(timezone.utc) + req_body['till'] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) req_body['rtime'] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) req_body['nonce'] = 123456789 # Can be any value From 58a635fda51f8d082e3c17bcb09668a4a21f51e1 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:59:37 +0100 Subject: [PATCH 09/26] feat: Use random nonce for Kerberos AS-REQ to enhance security --- nxc/protocols/kerberos/kerberosattacks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nxc/protocols/kerberos/kerberosattacks.py b/nxc/protocols/kerberos/kerberosattacks.py index 1ab17ee31e..bb6495b4bd 100644 --- a/nxc/protocols/kerberos/kerberosattacks.py +++ b/nxc/protocols/kerberos/kerberosattacks.py @@ -1,5 +1,6 @@ # Standard library imports import socket +import random from datetime import datetime, timezone # External library imports @@ -92,7 +93,7 @@ def check_user_exists(self, username: str) -> bool: req_body['till'] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) req_body['rtime'] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) - req_body['nonce'] = 123456789 # Can be any value + req_body['nonce'] = random.randint(1, 2147483647) # Random 32-bit positive integer # Set encryption types - prefer AES supported_ciphers = ( From fdb64207396acf3e3142b158781d9daee995993d Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:02:20 +0100 Subject: [PATCH 10/26] refactor: Simplify docstring in database module for clarity --- nxc/protocols/kerberos/database.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/nxc/protocols/kerberos/database.py b/nxc/protocols/kerberos/database.py index 9bcd0333d2..c8e83318be 100644 --- a/nxc/protocols/kerberos/database.py +++ b/nxc/protocols/kerberos/database.py @@ -1,11 +1,7 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - """ -Database module for Kerberos protocol +Database module for Kerberos protocol. -Currently minimal - just provides compatibility with NetExec's database structure. -Can be extended in the future to store enumeration results, discovered users, etc. +Currently minimal. """ From 87b4a44d2dd0f25001befa1e555ae73f6390afe5 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:09:54 +0100 Subject: [PATCH 11/26] refactor: Clean up code formatting and improve consistency in Kerberos and LDAP modules --- nxc/helpers/misc.py | 6 ++--- nxc/protocols/kerberos.py | 23 ++++++---------- nxc/protocols/kerberos/kerberosattacks.py | 33 +++++++++++------------ nxc/protocols/ldap/kerberos.py | 5 ++-- 4 files changed, 28 insertions(+), 39 deletions(-) diff --git a/nxc/helpers/misc.py b/nxc/helpers/misc.py index a08d5b204a..51dbe1140b 100755 --- a/nxc/helpers/misc.py +++ b/nxc/helpers/misc.py @@ -4,7 +4,6 @@ import re import inspect import os -import threading from termcolor import colored from ipaddress import ip_address from nxc.logger import nxc_logger @@ -13,7 +12,6 @@ from functools import wraps - def threaded_enumeration(items_param="items", max_workers=None, progress_threshold=100, show_progress=True): """ Decorator to add multi-threading support to enumeration methods. @@ -111,7 +109,7 @@ def wrapper(*args, **kwargs): # Determine max_workers: use decorator parameter, then self.args.threads, then default 10 workers = max_workers if workers is None: - if instance and hasattr(instance, 'args') and hasattr(instance.args, 'threads'): + if instance and hasattr(instance, "args") and hasattr(instance.args, "threads"): workers = instance.args.threads nxc_logger.debug(f"Using {workers} threads from --threads argument") else: @@ -128,7 +126,7 @@ def process_item(item): new_kwargs[items_param] = item # Remove 'self' from kwargs if present - new_kwargs.pop('self', None) + new_kwargs.pop("self", None) # Call function with instance if it's a method if instance is not None: diff --git a/nxc/protocols/kerberos.py b/nxc/protocols/kerberos.py index 1c134cf289..acf951164e 100644 --- a/nxc/protocols/kerberos.py +++ b/nxc/protocols/kerberos.py @@ -1,21 +1,16 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- import socket import time import binascii from impacket.krb5 import constants -from impacket.krb5.asn1 import AS_REQ, AS_REP, TGS_REQ, TGS_REP, KRB_ERROR -from impacket.krb5.kerberosv5 import sendReceive, KerberosError, getKerberosTGT +from impacket.krb5.kerberosv5 import getKerberosTGT from impacket.krb5.types import Principal, KerberosException -from impacket.krb5.ccache import CCache -from pyasn1.codec.der import decoder from nxc.connection import connection -from nxc.config import host_info_colors, process_secret +from nxc.config import process_secret from nxc.logger import NXCAdapter from nxc.helpers.misc import threaded_enumeration -from nxc.helpers.logger import highlight from nxc.protocols.kerberos.kerberosattacks import KerberosUserEnum @@ -45,7 +40,7 @@ def proto_logger(self): "protocol": "KRB5", "host": self.host, "port": self.port, - "hostname": self.hostname if hasattr(self, 'hostname') else self.host, + "hostname": self.hostname if hasattr(self, "hostname") else self.host, } ) @@ -67,7 +62,7 @@ def create_conn_obj(self): self.logger.fail(f"Kerberos port {self.port} is closed on {self.host}") return False - except socket.timeout: + except TimeoutError: self.logger.fail(f"Connection timeout to {self.host}:{self.port}") return False except Exception as e: @@ -131,7 +126,7 @@ def login(self): user_item = user_item.strip() try: # Try to open as file - with open(user_item, 'r') as f: + with open(user_item) as f: file_users = [line.strip() for line in f if line.strip()] usernames.extend(file_users) self.logger.info(f"Loaded {len(file_users)} usernames from {user_item}") @@ -411,8 +406,8 @@ def _enum_multiple_users(self, usernames): if self.args.log: output_file = f"{self.args.log}_valid_users.txt" try: - with open(output_file, 'w') as f: - f.write('\n'.join(valid_users)) + with open(output_file, "w") as f: + f.write("\n".join(valid_users)) self.logger.success(f"Valid usernames saved to {output_file}") except Exception as e: self.logger.fail(f"Error saving valid usernames: {e}") @@ -439,7 +434,7 @@ def _check_username_batch(self, usernames): ) # Add delay if requested (for stealth/rate limiting) - if hasattr(self.args, 'delay') and self.args.delay > 0: + if hasattr(self.args, "delay") and self.args.delay > 0: time.sleep(self.args.delay) result = kerberos_enum.check_user_exists(usernames) @@ -456,5 +451,3 @@ def _check_username_batch(self, usernames): else: self.logger.error(f"[!] {usernames}: {result}") return {"username": usernames, "status": "error", "error": result} - - diff --git a/nxc/protocols/kerberos/kerberosattacks.py b/nxc/protocols/kerberos/kerberosattacks.py index bb6495b4bd..75d7299200 100644 --- a/nxc/protocols/kerberos/kerberosattacks.py +++ b/nxc/protocols/kerberos/kerberosattacks.py @@ -1,5 +1,4 @@ # Standard library imports -import socket import random from datetime import datetime, timezone @@ -65,35 +64,35 @@ def check_user_exists(self, username: str) -> bool: as_req = AS_REQ() # Set domain - as_req['pvno'] = 5 - as_req['msg-type'] = int(constants.ApplicationTagNumbers.AS_REQ.value) + as_req["pvno"] = 5 + as_req["msg-type"] = int(constants.ApplicationTagNumbers.AS_REQ.value) # Request body - req_body = seq_set(as_req, 'req-body') + req_body = seq_set(as_req, "req-body") # KDC Options - request forwardable and renewable tickets opts = list() opts.append(constants.KDCOptions.forwardable.value) opts.append(constants.KDCOptions.renewable.value) opts.append(constants.KDCOptions.renewable_ok.value) - req_body['kdc-options'] = constants.encodeFlags(opts) + req_body["kdc-options"] = constants.encodeFlags(opts) # Set client principal - seq_set(req_body, 'cname', client_principal.components_to_asn1) - req_body['realm'] = self.domain + seq_set(req_body, "cname", client_principal.components_to_asn1) + req_body["realm"] = self.domain # Set server principal (krbtgt) server_principal = Principal( - f'krbtgt/{self.domain}', + f"krbtgt/{self.domain}", type=constants.PrincipalNameType.NT_PRINCIPAL.value ) - seq_set(req_body, 'sname', server_principal.components_to_asn1) + seq_set(req_body, "sname", server_principal.components_to_asn1) now = datetime.now(timezone.utc) - req_body['till'] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) - req_body['rtime'] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) - req_body['nonce'] = random.randint(1, 2147483647) # Random 32-bit positive integer + req_body["till"] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) + req_body["rtime"] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) + req_body["nonce"] = random.randint(1, 2147483647) # Random 32-bit positive integer # Set encryption types - prefer AES supported_ciphers = ( @@ -101,7 +100,7 @@ def check_user_exists(self, username: str) -> bool: constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value, constants.EncryptionTypes.rc4_hmac.value, ) - seq_set_iter(req_body, 'etype', supported_ciphers) + seq_set_iter(req_body, "etype", supported_ciphers) # No preauthentication data (this is key for enumeration) # We deliberately don't include PA-DATA to trigger preauth required response @@ -133,19 +132,17 @@ def check_user_exists(self, username: str) -> bool: else: # Other Kerberos error - error_msg = constants.ErrorCodes(error_code).name if hasattr(constants.ErrorCodes, '_value2member_map_') else str(error_code) + error_msg = constants.ErrorCodes(error_code).name if hasattr(constants.ErrorCodes, "_value2member_map_") else str(error_code) nxc_logger.debug(f"Kerberos error for {username}: {error_msg}") return f"KRB_ERROR_{error_code}" # If we get an AS-REP without error, user exists (very rare without preauth) return True - except socket.timeout: + except TimeoutError: return "TIMEOUT" - except socket.error as e: + except OSError as e: return f"SOCKET_ERROR: {e}" except Exception as e: nxc_logger.debug(f"Unexpected error checking {username}: {e}") return f"ERROR: {e}" - - diff --git a/nxc/protocols/ldap/kerberos.py b/nxc/protocols/ldap/kerberos.py index 02775ad4c4..f0ee83c0b0 100644 --- a/nxc/protocols/ldap/kerberos.py +++ b/nxc/protocols/ldap/kerberos.py @@ -311,13 +311,14 @@ def get_tgt_asroast(self, userName, requestPAC=True): def check_user_exists(self, userName): """ Check if a user exists via Kerberos AS-REQ without pre-authentication. + Returns: True: User exists (got KDC_ERR_PREAUTH_REQUIRED or AS_REP) False: User does not exist (got KDC_ERR_C_PRINCIPAL_UNKNOWN) None: Unexpected error occurred """ nxc_logger.debug(f"Checking if user {userName} exists via AS-REQ") - + client_name = Principal(userName, type=constants.PrincipalNameType.NT_PRINCIPAL.value) as_req = AS_REQ() @@ -340,7 +341,7 @@ def check_user_exists(self, userName): return None req_body["realm"] = domain - + # Set time parameters now = datetime.utcnow() + timedelta(days=1) if utc_failed else datetime.now(UTC) + timedelta(days=1) req_body["till"] = KerberosTime.to_asn1(now) From a799677be28f5cde136b0ecf5d183c8bf42f4f14 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:41:09 +0100 Subject: [PATCH 12/26] feat: Add Kerberos command examples to e2e_commands.txt --- tests/e2e_commands.txt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 84d5eeece8..5cca0b1c33 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -71,7 +71,7 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M add-comp netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M add-computer -o NAME="BADPC" DELETE=True netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M bitlocker netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dpapi_hash -netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dpapi_hash -o OUTPUTFILE=hashes.txt +netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dpapi_hash -o OUTPUTFILE=hashes.txt netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M drop-sc netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M drop-sc -o CLEANUP=True netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M empire_exec -o LISTENER=http-listener @@ -184,7 +184,7 @@ netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -x whoami ##### WMI Modules netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M ioxidresolver netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M spooler -netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M zerologon +netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M zerologon netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M enum_dns netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get_netconnections #netexec wmi TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M rdp -o ACTION=enable @@ -222,6 +222,15 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M subnets netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M user-desc netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M whoami netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dump-computers +##### KERBEROS +netexec kerberos TARGET_HOST -d DOMAIN -u LOGIN_USERNAME +netexec kerberos TARGET_HOST -d DOMAIN -u TEST_USER_FILE +netexec kerberos TARGET_HOST -d DOMAIN -u LOGIN_USERNAME --dc-ip DC_IP +netexec kerberos TARGET_HOST -d DOMAIN -u LOGIN_USERNAME -p LOGIN_PASSWORD +netexec kerberos TARGET_HOST -d DOMAIN -u TEST_USER_FILE -p LOGIN_PASSWORD +netexec kerberos TARGET_HOST -d DOMAIN -u TEST_USER_FILE -p TEST_PASSWORD_FILE --no-bruteforce +netexec kerberos TARGET_HOST -d DOMAIN -u TEST_USER_FILE -p TEST_PASSWORD_FILE --no-bruteforce --continue-on-success +netexec kerberos TARGET_HOST -d DOMAIN -u LOGIN_USERNAME ##### WINRM netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # need an extra space after this command due to regex netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig From 75f21fd02cc16b9259c9796642f871f2f799d85d Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:41:18 +0100 Subject: [PATCH 13/26] refactor: Simplify connection object creation and user existence check in Kerberos modules --- nxc/protocols/kerberos.py | 21 ++++++--------------- nxc/protocols/kerberos/kerberosattacks.py | 4 ++-- nxc/protocols/ldap/kerberos.py | 6 ++---- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/nxc/protocols/kerberos.py b/nxc/protocols/kerberos.py index acf951164e..adcc2b7bd5 100644 --- a/nxc/protocols/kerberos.py +++ b/nxc/protocols/kerberos.py @@ -3,6 +3,7 @@ import socket import time import binascii +import contextlib from impacket.krb5 import constants from impacket.krb5.kerberosv5 import getKerberosTGT from impacket.krb5.types import Principal, KerberosException @@ -45,9 +46,7 @@ def proto_logger(self): ) def create_conn_obj(self): - """ - Create connection object (minimal for Kerberos - just validate KDC is reachable) - """ + """Create connection object (minimal for Kerberos - just validate KDC is reachable)""" try: # Test if the KDC port is open sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -70,9 +69,7 @@ def create_conn_obj(self): return False def enum_host_info(self): - """ - Enumerate basic host information - """ + """Enumerate basic host information""" # Set domain from args if self.args.domain: self.domain = self.args.domain.upper() @@ -204,9 +201,7 @@ def plaintext_login(self, domain, username, password): return False def hash_login(self, domain, username, ntlm_hash): - """ - Authenticate to Kerberos KDC using username and NTLM hash. - """ + """Authenticate to Kerberos KDC using username and NTLM hash.""" self.username = username self.domain = domain @@ -264,9 +259,7 @@ def hash_login(self, domain, username, ntlm_hash): return False def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False): - """ - Authenticate using Kerberos with various credential types. - """ + """Authenticate using Kerberos with various credential types.""" self.username = username self.password = password self.domain = domain @@ -356,15 +349,13 @@ def _check_single_user(self, username): if result is True: self.logger.success(f"{self.domain}\\{username}") # Add to database - try: + with contextlib.suppress(Exception): self.db.add_host( self.host, self.hostname, self.domain, "Kerberos" ) - except Exception: - pass return True elif result == "ACCOUNT_DISABLED": self.logger.highlight(f"{self.domain}\\{username} (disabled)") diff --git a/nxc/protocols/kerberos/kerberosattacks.py b/nxc/protocols/kerberos/kerberosattacks.py index 75d7299200..7c679acd8f 100644 --- a/nxc/protocols/kerberos/kerberosattacks.py +++ b/nxc/protocols/kerberos/kerberosattacks.py @@ -71,7 +71,7 @@ def check_user_exists(self, username: str) -> bool: req_body = seq_set(as_req, "req-body") # KDC Options - request forwardable and renewable tickets - opts = list() + opts = [] opts.append(constants.KDCOptions.forwardable.value) opts.append(constants.KDCOptions.renewable.value) opts.append(constants.KDCOptions.renewable_ok.value) @@ -109,7 +109,7 @@ def check_user_exists(self, username: str) -> bool: message = encoder.encode(as_req) try: - response = sendReceive(message, self.domain, self.kdcHost) + sendReceive(message, self.domain, self.kdcHost) except KerberosError as e: # Analyze the error code to determine user status error_code = e.getErrorCode() diff --git a/nxc/protocols/ldap/kerberos.py b/nxc/protocols/ldap/kerberos.py index f0ee83c0b0..2224235a6d 100644 --- a/nxc/protocols/ldap/kerberos.py +++ b/nxc/protocols/ldap/kerberos.py @@ -393,7 +393,7 @@ def check_user_exists(self, userName): # If we get here, we received a response (likely AS_REP) # This means the user exists and doesn't require pre-auth try: - as_rep = decoder.decode(r, asn1Spec=AS_REP())[0] + decoder.decode(r, asn1Spec=AS_REP())[0] nxc_logger.debug(f"User {userName} exists (no pre-auth required, received AS_REP)") return True except Exception: @@ -402,9 +402,7 @@ def check_user_exists(self, userName): krb_error = decoder.decode(r, asn1Spec=KRB_ERROR())[0] error_code = krb_error["error-code"] nxc_logger.debug(f"User {userName} returned KRB_ERROR with code: {error_code}") - if error_code == constants.ErrorCodes.KDC_ERR_C_PRINCIPAL_UNKNOWN.value: - return False - return True + return error_code != constants.ErrorCodes.KDC_ERR_C_PRINCIPAL_UNKNOWN.value except Exception: nxc_logger.debug(f"Unexpected response format for user {userName}") return None From 1720dc7a4ae3b7f836ef64989bf1fb858168bc94 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:45:38 +0100 Subject: [PATCH 14/26] feat: Enhance database module for Kerberos protocol --- nxc/protocols/kerberos/database.py | 245 ++++++++++++++++++++++++++--- 1 file changed, 220 insertions(+), 25 deletions(-) diff --git a/nxc/protocols/kerberos/database.py b/nxc/protocols/kerberos/database.py index c8e83318be..4d9c2d432f 100644 --- a/nxc/protocols/kerberos/database.py +++ b/nxc/protocols/kerberos/database.py @@ -1,33 +1,228 @@ -""" -Database module for Kerberos protocol. +import sys -Currently minimal. -""" +from sqlalchemy import func, Table, select, delete +from sqlalchemy.dialects.sqlite import Insert # used for upsert +from sqlalchemy.exc import ( + NoInspectionAvailable, + NoSuchTableError, +) +from nxc.database import BaseDB, format_host_query +from nxc.logger import nxc_logger -class database: - """Kerberos protocol database handler""" +class database(BaseDB): def __init__(self, db_engine): - self.db_engine = db_engine + self.UsersTable = None + self.HostsTable = None + + super().__init__(db_engine) @staticmethod def db_schema(db_conn): - """ - Define database schema for Kerberos protocol - - Currently minimal - can be extended to store: - - Enumerated users - - AS-REP roastable accounts - - Timing information for stealth tracking - - etc. - """ - db_conn.execute("""CREATE TABLE IF NOT EXISTS kerberos_users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER, - domain TEXT, - username TEXT, - status TEXT, - timestamp TEXT, - FOREIGN KEY(host_id) REFERENCES hosts(id) - )""") + db_conn.execute( + """CREATE TABLE "users" ( + "id" integer PRIMARY KEY, + "domain" text, + "username" text, + "password" text, + "credtype" text, + "pillaged_from_hostid" integer, + FOREIGN KEY(pillaged_from_hostid) REFERENCES hosts(id) + )""" + ) + + db_conn.execute( + """CREATE TABLE "hosts" ( + "id" integer PRIMARY KEY, + "ip" text, + "hostname" text, + "domain" text, + "os" text + )""" + ) + + def reflect_tables(self): + with self.db_engine.connect(): + try: + self.UsersTable = Table("users", self.metadata, autoload_with=self.db_engine) + self.HostsTable = Table("hosts", self.metadata, autoload_with=self.db_engine) + except (NoInspectionAvailable, NoSuchTableError): + print( + f""" + [-] Error reflecting tables for the {self.protocol} protocol - this means there is a DB schema mismatch + [-] This is probably because a newer version of nxc is being run on an old DB schema + [-] Optionally save the old DB data (`cp {self.db_path} ~/nxc_{self.protocol.lower()}.bak`) + [-] Then remove the nxc {self.protocol} DB (`rm -f {self.db_path}`) and run nxc to initialize the new DB""" + ) + sys.exit() + + def add_host(self, ip, hostname, domain, os): + """Check if this host has already been added to the database, if not, add it in.""" + hosts = [] + updated_ids = [] + + q = select(self.HostsTable).filter(self.HostsTable.c.ip == ip) + results = self.db_execute(q).all() + + # create new host + if not results: + new_host = { + "ip": ip, + "hostname": hostname, + "domain": domain, + "os": os + } + hosts = [new_host] + # update existing hosts data + else: + for host in results: + host_data = host._asdict() + # only update column if it is being passed in + if ip is not None: + host_data["ip"] = ip + if hostname is not None: + host_data["hostname"] = hostname + if domain is not None: + host_data["domain"] = domain + # only add host to be updated if it has changed + if host_data not in hosts: + hosts.append(host_data) + updated_ids.append(host_data["id"]) + nxc_logger.debug(f"Update Hosts: {hosts}") + + # TODO: find a way to abstract this away to a single Upsert call + q = Insert(self.HostsTable) # .returning(self.HostsTable.c.id) + update_columns = {col.name: col for col in q.excluded if col.name not in "id"} + q = q.on_conflict_do_update(index_elements=self.HostsTable.primary_key, set_=update_columns) + + self.db_execute(q, hosts) # .scalar() + # we only return updated IDs for now - when RETURNING clause is allowed we can return inserted + if updated_ids: + nxc_logger.debug(f"add_host() - Host IDs Updated: {updated_ids}") + return updated_ids + + def add_credential(self, credtype, domain, username, password, pillaged_from=None): + """Check if this credential has already been added to the database, if not add it in.""" + credentials = [] + + if pillaged_from and not self.is_host_valid(pillaged_from): + nxc_logger.debug("Invalid host") + return + + q = select(self.UsersTable).filter( + func.lower(self.UsersTable.c.domain) == func.lower(domain), + func.lower(self.UsersTable.c.username) == func.lower(username), + func.lower(self.UsersTable.c.credtype) == func.lower(credtype), + ) + results = self.db_execute(q).all() + + # add new credential + if not results: + new_cred = { + "credtype": credtype, + "domain": domain, + "username": username, + "password": password, + "pillaged_from_hostid": pillaged_from, + } + credentials = [new_cred] + # update existing cred data + else: + for creds in results: + # this will include the id, so we don't touch it + cred_data = creds._asdict() + # only update column if it is being passed in + if credtype is not None: + cred_data["credtype"] = credtype + if domain is not None: + cred_data["domain"] = domain + if username is not None: + cred_data["username"] = username + if password is not None: + cred_data["password"] = password + if pillaged_from is not None: + cred_data["pillaged_from_hostid"] = pillaged_from + # only add cred to be updated if it has changed + if cred_data not in credentials: + credentials.append(cred_data) + + # TODO: find a way to abstract this away to a single Upsert call + q_users = Insert(self.UsersTable) # .returning(self.UsersTable.c.id) + update_columns_users = {col.name: col for col in q_users.excluded if col.name not in "id"} + q_users = q_users.on_conflict_do_update(index_elements=self.UsersTable.primary_key, set_=update_columns_users) + nxc_logger.debug(f"Adding credentials: {credentials}") + + self.db_execute(q_users, credentials) # .scalar() + + def remove_credentials(self, creds_id): + """Removes a credential ID from the database""" + del_hosts = [] + for cred_id in creds_id: + q = delete(self.UsersTable).filter(self.UsersTable.c.id == cred_id) + del_hosts.append(q) + self.db_execute(q) + + def is_credential_valid(self, credential_id): + """Check if this credential ID is valid.""" + q = select(self.UsersTable).filter( + self.UsersTable.c.id == credential_id, + self.UsersTable.c.password is not None, + ) + results = self.db_execute(q).all() + return len(results) > 0 + + def get_credentials(self, filter_term=None, cred_type=None): + """Return credentials from the database.""" + # if we're returning a single credential by ID + if self.is_credential_valid(filter_term): + q = select(self.UsersTable).filter(self.UsersTable.c.id == filter_term) + elif cred_type: + q = select(self.UsersTable).filter(self.UsersTable.c.credtype == cred_type) + # if we're filtering by username + elif filter_term and filter_term != "": + like_term = func.lower(f"%{filter_term}%") + q = select(self.UsersTable).filter(func.lower(self.UsersTable.c.username).like(like_term)) + # otherwise return all credentials + else: + q = select(self.UsersTable) + + return self.db_execute(q).all() + + def get_credential(self, cred_type, domain, username, password): + q = select(self.UsersTable).filter( + self.UsersTable.c.domain == domain, + self.UsersTable.c.username == username, + self.UsersTable.c.password == password, + self.UsersTable.c.credtype == cred_type, + ) + results = self.db_execute(q).first() + return results.id + + def get_hosts(self, filter_term=None, domain=None): + """Return hosts from the database.""" + q = select(self.HostsTable) + + # if we're returning a single host by ID + if self.is_host_valid(filter_term): + q = q.filter(self.HostsTable.c.id == filter_term) + results = self.db_execute(q).first() + # all() returns a list, so we keep the return format the same so consumers don't have to guess + return [results] + elif filter_term is not None and filter_term.startswith("domain"): + domain = filter_term.split()[1] + like_term = func.lower(f"%{domain}%") + q = q.filter(self.HostsTable.c.domain.like(like_term)) + # if we're filtering by ip/hostname + elif filter_term and filter_term != "": + q = format_host_query(q, filter_term, self.HostsTable) + + results = self.db_execute(q).all() + nxc_logger.debug(f"kerberos hosts() - results: {results}") + return results + + def is_host_valid(self, host_id): + """Check if this host ID is valid.""" + q = select(self.HostsTable).filter(self.HostsTable.c.id == host_id) + results = self.db_execute(q).all() + return len(results) > 0 From 6e8a5d107a972f33d1ce358776d133b82fdb8fb8 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:15:03 +0100 Subject: [PATCH 15/26] feat: Add users export option to Kerberos user enumeration --- nxc/protocols/kerberos.py | 4 ++-- nxc/protocols/kerberos/proto_args.py | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/nxc/protocols/kerberos.py b/nxc/protocols/kerberos.py index adcc2b7bd5..76b4e6dcc2 100644 --- a/nxc/protocols/kerberos.py +++ b/nxc/protocols/kerberos.py @@ -394,8 +394,8 @@ def _enum_multiple_users(self, usernames): self.logger.display(f"Valid usernames: {', '.join(valid_users)}") # Save to file if requested - if self.args.log: - output_file = f"{self.args.log}_valid_users.txt" + if hasattr(self.args, "users_export") and self.args.users_export: + output_file = self.args.users_export try: with open(output_file, "w") as f: f.write("\n".join(valid_users)) diff --git a/nxc/protocols/kerberos/proto_args.py b/nxc/protocols/kerberos/proto_args.py index 83bdde42f5..99307574e2 100644 --- a/nxc/protocols/kerberos/proto_args.py +++ b/nxc/protocols/kerberos/proto_args.py @@ -34,4 +34,9 @@ def proto_args(parser, parents): help="IP address or FQDN of the Domain Controller (KDC)" ) + kerberos_parser.add_argument( + "--users-export", + help="Enumerate domain users and export them to the specified file" + ) + return parser From 3a5149876b7643c57e0d311a5a47206d42ef25b5 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:41:25 +0100 Subject: [PATCH 16/26] refactor: Remove unused check_user_exists method from KerberosAttacks class --- nxc/protocols/ldap/kerberos.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/nxc/protocols/ldap/kerberos.py b/nxc/protocols/ldap/kerberos.py index 2224235a6d..599d07e74d 100644 --- a/nxc/protocols/ldap/kerberos.py +++ b/nxc/protocols/ldap/kerberos.py @@ -307,8 +307,6 @@ def get_tgt_asroast(self, userName, requestPAC=True): else: hash_tgt += f"{hexlify(as_rep['enc-part']['cipher'].asOctets()[:16]).decode()}${hexlify(as_rep['enc-part']['cipher'].asOctets()[16:]).decode()}" return hash_tgt - - def check_user_exists(self, userName): """ Check if a user exists via Kerberos AS-REQ without pre-authentication. From 785b3eef654056d6f1fcd2245380f211a0a76b84 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:43:28 +0100 Subject: [PATCH 17/26] Remove user existence check in Kerberos function Signed-off-by: n3rada <72791564+n3rada@users.noreply.github.com> --- nxc/protocols/ldap/kerberos.py | 97 ---------------------------------- 1 file changed, 97 deletions(-) diff --git a/nxc/protocols/ldap/kerberos.py b/nxc/protocols/ldap/kerberos.py index 599d07e74d..c754d80ec4 100644 --- a/nxc/protocols/ldap/kerberos.py +++ b/nxc/protocols/ldap/kerberos.py @@ -307,100 +307,3 @@ def get_tgt_asroast(self, userName, requestPAC=True): else: hash_tgt += f"{hexlify(as_rep['enc-part']['cipher'].asOctets()[:16]).decode()}${hexlify(as_rep['enc-part']['cipher'].asOctets()[16:]).decode()}" return hash_tgt - """ - Check if a user exists via Kerberos AS-REQ without pre-authentication. - - Returns: - True: User exists (got KDC_ERR_PREAUTH_REQUIRED or AS_REP) - False: User does not exist (got KDC_ERR_C_PRINCIPAL_UNKNOWN) - None: Unexpected error occurred - """ - nxc_logger.debug(f"Checking if user {userName} exists via AS-REQ") - - client_name = Principal(userName, type=constants.PrincipalNameType.NT_PRINCIPAL.value) - as_req = AS_REQ() - - domain = self.targetDomain.upper() - server_name = Principal(f"krbtgt/{domain}", type=constants.PrincipalNameType.NT_PRINCIPAL.value) - - as_req["pvno"] = 5 - as_req["msg-type"] = int(constants.ApplicationTagNumbers.AS_REQ.value) - - req_body = seq_set(as_req, "req-body") - - opts = [constants.KDCOptions.forwardable.value] - req_body["kdc-options"] = constants.encodeFlags(opts) - - seq_set(req_body, "sname", server_name.components_to_asn1) - seq_set(req_body, "cname", client_name.components_to_asn1) - - if domain == "": - nxc_logger.error("Empty Domain not allowed in Kerberos") - return None - - req_body["realm"] = domain - - # Set time parameters - now = datetime.utcnow() + timedelta(days=1) if utc_failed else datetime.now(UTC) + timedelta(days=1) - req_body["till"] = KerberosTime.to_asn1(now) - req_body["rtime"] = KerberosTime.to_asn1(now) - req_body["nonce"] = random.getrandbits(31) - - # Request multiple encryption types to maximize compatibility - supported_ciphers = ( - int(constants.EncryptionTypes.rc4_hmac.value), - int(constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value), - int(constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value), - ) - seq_set_iter(req_body, "etype", supported_ciphers) - - message = encoder.encode(as_req) - - # If kdcHost isn't set, use the target IP for DNS resolution - if not self.kdcHost: - self.kdcHost = self.host - - try: - r = sendReceive(message, domain, self.kdcHost) - except KerberosError as e: - error_code = e.getErrorCode() - if error_code == constants.ErrorCodes.KDC_ERR_C_PRINCIPAL_UNKNOWN.value: - # User does not exist - nxc_logger.debug(f"User {userName} does not exist (KDC_ERR_C_PRINCIPAL_UNKNOWN)") - return False - elif error_code == constants.ErrorCodes.KDC_ERR_PREAUTH_REQUIRED.value: - # User exists and requires pre-authentication (normal) - nxc_logger.debug(f"User {userName} exists (KDC_ERR_PREAUTH_REQUIRED)") - return True - elif error_code == constants.ErrorCodes.KDC_ERR_CLIENT_REVOKED.value: - # User exists but account is disabled - nxc_logger.debug(f"User {userName} exists but account is disabled (KDC_ERR_CLIENT_REVOKED)") - return True - elif error_code == constants.ErrorCodes.KDC_ERR_KEY_EXPIRED.value: - # User exists but password expired - nxc_logger.debug(f"User {userName} exists but password expired (KDC_ERR_KEY_EXPIRED)") - return True - else: - # Unexpected error - nxc_logger.debug(f"Unexpected Kerberos error for {userName}: {e} (code: {error_code})") - return None - except Exception as e: - nxc_logger.debug(f"Unexpected error checking user {userName}: {e}") - return None - - # If we get here, we received a response (likely AS_REP) - # This means the user exists and doesn't require pre-auth - try: - decoder.decode(r, asn1Spec=AS_REP())[0] - nxc_logger.debug(f"User {userName} exists (no pre-auth required, received AS_REP)") - return True - except Exception: - # Could be a different response type - try: - krb_error = decoder.decode(r, asn1Spec=KRB_ERROR())[0] - error_code = krb_error["error-code"] - nxc_logger.debug(f"User {userName} returned KRB_ERROR with code: {error_code}") - return error_code != constants.ErrorCodes.KDC_ERR_C_PRINCIPAL_UNKNOWN.value - except Exception: - nxc_logger.debug(f"Unexpected response format for user {userName}") - return None From ea46524e8768cc1fe1a42c9b2472b8d65a95e7a3 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:45:49 +0100 Subject: [PATCH 18/26] Add local library import for nxc_logger Signed-off-by: n3rada <72791564+n3rada@users.noreply.github.com> --- nxc/protocols/kerberos/kerberosattacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/kerberos/kerberosattacks.py b/nxc/protocols/kerberos/kerberosattacks.py index 7c679acd8f..42c992350e 100644 --- a/nxc/protocols/kerberos/kerberosattacks.py +++ b/nxc/protocols/kerberos/kerberosattacks.py @@ -9,9 +9,9 @@ from impacket.krb5.types import Principal, KerberosTime from pyasn1.codec.der import encoder +# Local library imports from nxc.logger import nxc_logger - class KerberosUserEnum: """ Kerberos User Enumeration Class From f7dcd5f286eff67dc068f9b9fccf97c1dbfcc1f2 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:46:27 +0100 Subject: [PATCH 19/26] Add built-in imports section to kerberos.py Signed-off-by: n3rada <72791564+n3rada@users.noreply.github.com> --- nxc/protocols/kerberos.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/nxc/protocols/kerberos.py b/nxc/protocols/kerberos.py index 76b4e6dcc2..618d352c1b 100644 --- a/nxc/protocols/kerberos.py +++ b/nxc/protocols/kerberos.py @@ -1,20 +1,21 @@ -#!/usr/bin/env python3 - +# Built-in imports import socket import time import binascii import contextlib + +# Third party imports from impacket.krb5 import constants from impacket.krb5.kerberosv5 import getKerberosTGT from impacket.krb5.types import Principal, KerberosException +# Local library imports from nxc.connection import connection from nxc.config import process_secret from nxc.logger import NXCAdapter from nxc.helpers.misc import threaded_enumeration from nxc.protocols.kerberos.kerberosattacks import KerberosUserEnum - class kerberos(connection): """ Kerberos protocol implementation for NetExec. From 3a7c9be50260f559e9932a12d22d974c52791db3 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Sun, 9 Nov 2025 11:45:30 +0100 Subject: [PATCH 20/26] refactor: Remove Kerberos protocol files --- nxc/protocols/kerberos/__init__.py | 0 nxc/protocols/kerberos/database.py | 228 ---------------------- nxc/protocols/kerberos/kerberosattacks.py | 148 -------------- nxc/protocols/kerberos/proto_args.py | 42 ---- 4 files changed, 418 deletions(-) delete mode 100644 nxc/protocols/kerberos/__init__.py delete mode 100644 nxc/protocols/kerberos/database.py delete mode 100644 nxc/protocols/kerberos/kerberosattacks.py delete mode 100644 nxc/protocols/kerberos/proto_args.py diff --git a/nxc/protocols/kerberos/__init__.py b/nxc/protocols/kerberos/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/nxc/protocols/kerberos/database.py b/nxc/protocols/kerberos/database.py deleted file mode 100644 index 4d9c2d432f..0000000000 --- a/nxc/protocols/kerberos/database.py +++ /dev/null @@ -1,228 +0,0 @@ -import sys - -from sqlalchemy import func, Table, select, delete -from sqlalchemy.dialects.sqlite import Insert # used for upsert -from sqlalchemy.exc import ( - NoInspectionAvailable, - NoSuchTableError, -) - -from nxc.database import BaseDB, format_host_query -from nxc.logger import nxc_logger - - -class database(BaseDB): - def __init__(self, db_engine): - self.UsersTable = None - self.HostsTable = None - - super().__init__(db_engine) - - @staticmethod - def db_schema(db_conn): - db_conn.execute( - """CREATE TABLE "users" ( - "id" integer PRIMARY KEY, - "domain" text, - "username" text, - "password" text, - "credtype" text, - "pillaged_from_hostid" integer, - FOREIGN KEY(pillaged_from_hostid) REFERENCES hosts(id) - )""" - ) - - db_conn.execute( - """CREATE TABLE "hosts" ( - "id" integer PRIMARY KEY, - "ip" text, - "hostname" text, - "domain" text, - "os" text - )""" - ) - - def reflect_tables(self): - with self.db_engine.connect(): - try: - self.UsersTable = Table("users", self.metadata, autoload_with=self.db_engine) - self.HostsTable = Table("hosts", self.metadata, autoload_with=self.db_engine) - except (NoInspectionAvailable, NoSuchTableError): - print( - f""" - [-] Error reflecting tables for the {self.protocol} protocol - this means there is a DB schema mismatch - [-] This is probably because a newer version of nxc is being run on an old DB schema - [-] Optionally save the old DB data (`cp {self.db_path} ~/nxc_{self.protocol.lower()}.bak`) - [-] Then remove the nxc {self.protocol} DB (`rm -f {self.db_path}`) and run nxc to initialize the new DB""" - ) - sys.exit() - - def add_host(self, ip, hostname, domain, os): - """Check if this host has already been added to the database, if not, add it in.""" - hosts = [] - updated_ids = [] - - q = select(self.HostsTable).filter(self.HostsTable.c.ip == ip) - results = self.db_execute(q).all() - - # create new host - if not results: - new_host = { - "ip": ip, - "hostname": hostname, - "domain": domain, - "os": os - } - hosts = [new_host] - # update existing hosts data - else: - for host in results: - host_data = host._asdict() - # only update column if it is being passed in - if ip is not None: - host_data["ip"] = ip - if hostname is not None: - host_data["hostname"] = hostname - if domain is not None: - host_data["domain"] = domain - # only add host to be updated if it has changed - if host_data not in hosts: - hosts.append(host_data) - updated_ids.append(host_data["id"]) - nxc_logger.debug(f"Update Hosts: {hosts}") - - # TODO: find a way to abstract this away to a single Upsert call - q = Insert(self.HostsTable) # .returning(self.HostsTable.c.id) - update_columns = {col.name: col for col in q.excluded if col.name not in "id"} - q = q.on_conflict_do_update(index_elements=self.HostsTable.primary_key, set_=update_columns) - - self.db_execute(q, hosts) # .scalar() - # we only return updated IDs for now - when RETURNING clause is allowed we can return inserted - if updated_ids: - nxc_logger.debug(f"add_host() - Host IDs Updated: {updated_ids}") - return updated_ids - - def add_credential(self, credtype, domain, username, password, pillaged_from=None): - """Check if this credential has already been added to the database, if not add it in.""" - credentials = [] - - if pillaged_from and not self.is_host_valid(pillaged_from): - nxc_logger.debug("Invalid host") - return - - q = select(self.UsersTable).filter( - func.lower(self.UsersTable.c.domain) == func.lower(domain), - func.lower(self.UsersTable.c.username) == func.lower(username), - func.lower(self.UsersTable.c.credtype) == func.lower(credtype), - ) - results = self.db_execute(q).all() - - # add new credential - if not results: - new_cred = { - "credtype": credtype, - "domain": domain, - "username": username, - "password": password, - "pillaged_from_hostid": pillaged_from, - } - credentials = [new_cred] - # update existing cred data - else: - for creds in results: - # this will include the id, so we don't touch it - cred_data = creds._asdict() - # only update column if it is being passed in - if credtype is not None: - cred_data["credtype"] = credtype - if domain is not None: - cred_data["domain"] = domain - if username is not None: - cred_data["username"] = username - if password is not None: - cred_data["password"] = password - if pillaged_from is not None: - cred_data["pillaged_from_hostid"] = pillaged_from - # only add cred to be updated if it has changed - if cred_data not in credentials: - credentials.append(cred_data) - - # TODO: find a way to abstract this away to a single Upsert call - q_users = Insert(self.UsersTable) # .returning(self.UsersTable.c.id) - update_columns_users = {col.name: col for col in q_users.excluded if col.name not in "id"} - q_users = q_users.on_conflict_do_update(index_elements=self.UsersTable.primary_key, set_=update_columns_users) - nxc_logger.debug(f"Adding credentials: {credentials}") - - self.db_execute(q_users, credentials) # .scalar() - - def remove_credentials(self, creds_id): - """Removes a credential ID from the database""" - del_hosts = [] - for cred_id in creds_id: - q = delete(self.UsersTable).filter(self.UsersTable.c.id == cred_id) - del_hosts.append(q) - self.db_execute(q) - - def is_credential_valid(self, credential_id): - """Check if this credential ID is valid.""" - q = select(self.UsersTable).filter( - self.UsersTable.c.id == credential_id, - self.UsersTable.c.password is not None, - ) - results = self.db_execute(q).all() - return len(results) > 0 - - def get_credentials(self, filter_term=None, cred_type=None): - """Return credentials from the database.""" - # if we're returning a single credential by ID - if self.is_credential_valid(filter_term): - q = select(self.UsersTable).filter(self.UsersTable.c.id == filter_term) - elif cred_type: - q = select(self.UsersTable).filter(self.UsersTable.c.credtype == cred_type) - # if we're filtering by username - elif filter_term and filter_term != "": - like_term = func.lower(f"%{filter_term}%") - q = select(self.UsersTable).filter(func.lower(self.UsersTable.c.username).like(like_term)) - # otherwise return all credentials - else: - q = select(self.UsersTable) - - return self.db_execute(q).all() - - def get_credential(self, cred_type, domain, username, password): - q = select(self.UsersTable).filter( - self.UsersTable.c.domain == domain, - self.UsersTable.c.username == username, - self.UsersTable.c.password == password, - self.UsersTable.c.credtype == cred_type, - ) - results = self.db_execute(q).first() - return results.id - - def get_hosts(self, filter_term=None, domain=None): - """Return hosts from the database.""" - q = select(self.HostsTable) - - # if we're returning a single host by ID - if self.is_host_valid(filter_term): - q = q.filter(self.HostsTable.c.id == filter_term) - results = self.db_execute(q).first() - # all() returns a list, so we keep the return format the same so consumers don't have to guess - return [results] - elif filter_term is not None and filter_term.startswith("domain"): - domain = filter_term.split()[1] - like_term = func.lower(f"%{domain}%") - q = q.filter(self.HostsTable.c.domain.like(like_term)) - # if we're filtering by ip/hostname - elif filter_term and filter_term != "": - q = format_host_query(q, filter_term, self.HostsTable) - - results = self.db_execute(q).all() - nxc_logger.debug(f"kerberos hosts() - results: {results}") - return results - - def is_host_valid(self, host_id): - """Check if this host ID is valid.""" - q = select(self.HostsTable).filter(self.HostsTable.c.id == host_id) - results = self.db_execute(q).all() - return len(results) > 0 diff --git a/nxc/protocols/kerberos/kerberosattacks.py b/nxc/protocols/kerberos/kerberosattacks.py deleted file mode 100644 index 42c992350e..0000000000 --- a/nxc/protocols/kerberos/kerberosattacks.py +++ /dev/null @@ -1,148 +0,0 @@ -# Standard library imports -import random -from datetime import datetime, timezone - -# External library imports -from impacket.krb5 import constants -from impacket.krb5.asn1 import AS_REQ, seq_set, seq_set_iter -from impacket.krb5.kerberosv5 import sendReceive, KerberosError -from impacket.krb5.types import Principal, KerberosTime -from pyasn1.codec.der import encoder - -# Local library imports -from nxc.logger import nxc_logger - -class KerberosUserEnum: - """ - Kerberos User Enumeration Class - - Provides methods to enumerate valid Active Directory usernames via Kerberos - AS-REQ requests without triggering badPwdCount increments. - """ - - def __init__(self, domain, kdcHost, timeout=10): - """ - Initialize Kerberos User Enumeration - - Args: - domain (str): The target Active Directory domain (e.g., 'CORP.LOCAL') - kdcHost (str): IP address or FQDN of the KDC (Domain Controller) - timeout (int): Socket timeout in seconds - """ - self.domain = domain.upper() - self.kdcHost = kdcHost - self.timeout = timeout - - def check_user_exists(self, username: str) -> bool: - """ - Check if a username exists in Active Directory via Kerberos AS-REQ. - - This method sends a Kerberos AS-REQ (Authentication Service Request) with - no preauthentication data. The KDC's response reveals whether the user exists: - - - KDC_ERR_PREAUTH_REQUIRED (KDC_ERR_CODE 25): User exists (preauth required) - - KDC_ERR_C_PRINCIPAL_UNKNOWN (KDC_ERR_CODE 6): User does not exist - - KDC_ERR_CLIENT_REVOKED: User account is disabled - - Other errors: Various account/policy issues - - This method does NOT increment badPwdCount since no password is provided. - - Args: - username (str): Username to check (without domain) - - Returns: - bool or str: - - True if user exists and is valid - - False if user does not exist - - Error string if another error occurred - """ - try: - # Build the principal name - client_principal = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) - - # Build AS-REQ - as_req = AS_REQ() - - # Set domain - as_req["pvno"] = 5 - as_req["msg-type"] = int(constants.ApplicationTagNumbers.AS_REQ.value) - - # Request body - req_body = seq_set(as_req, "req-body") - - # KDC Options - request forwardable and renewable tickets - opts = [] - opts.append(constants.KDCOptions.forwardable.value) - opts.append(constants.KDCOptions.renewable.value) - opts.append(constants.KDCOptions.renewable_ok.value) - req_body["kdc-options"] = constants.encodeFlags(opts) - - # Set client principal - seq_set(req_body, "cname", client_principal.components_to_asn1) - req_body["realm"] = self.domain - - # Set server principal (krbtgt) - server_principal = Principal( - f"krbtgt/{self.domain}", - type=constants.PrincipalNameType.NT_PRINCIPAL.value - ) - seq_set(req_body, "sname", server_principal.components_to_asn1) - - now = datetime.now(timezone.utc) - - req_body["till"] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) - req_body["rtime"] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) - req_body["nonce"] = random.randint(1, 2147483647) # Random 32-bit positive integer - - # Set encryption types - prefer AES - supported_ciphers = ( - constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value, - constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value, - constants.EncryptionTypes.rc4_hmac.value, - ) - seq_set_iter(req_body, "etype", supported_ciphers) - - # No preauthentication data (this is key for enumeration) - # We deliberately don't include PA-DATA to trigger preauth required response - - # Encode and send the request - message = encoder.encode(as_req) - - try: - sendReceive(message, self.domain, self.kdcHost) - except KerberosError as e: - # Analyze the error code to determine user status - error_code = e.getErrorCode() - - if error_code == constants.ErrorCodes.KDC_ERR_PREAUTH_REQUIRED.value: - # User exists! (KDC requires preauthentication) - return True - - elif error_code == constants.ErrorCodes.KDC_ERR_C_PRINCIPAL_UNKNOWN.value: - # User does not exist - return False - - elif error_code == constants.ErrorCodes.KDC_ERR_CLIENT_REVOKED.value: - # User exists but account is disabled - nxc_logger.debug(f"User {username} exists but account is disabled") - return "ACCOUNT_DISABLED" - - elif error_code == constants.ErrorCodes.KDC_ERR_WRONG_REALM.value: - return "WRONG_REALM" - - else: - # Other Kerberos error - error_msg = constants.ErrorCodes(error_code).name if hasattr(constants.ErrorCodes, "_value2member_map_") else str(error_code) - nxc_logger.debug(f"Kerberos error for {username}: {error_msg}") - return f"KRB_ERROR_{error_code}" - - # If we get an AS-REP without error, user exists (very rare without preauth) - return True - - except TimeoutError: - return "TIMEOUT" - except OSError as e: - return f"SOCKET_ERROR: {e}" - except Exception as e: - nxc_logger.debug(f"Unexpected error checking {username}: {e}") - return f"ERROR: {e}" diff --git a/nxc/protocols/kerberos/proto_args.py b/nxc/protocols/kerberos/proto_args.py deleted file mode 100644 index 99307574e2..0000000000 --- a/nxc/protocols/kerberos/proto_args.py +++ /dev/null @@ -1,42 +0,0 @@ -from nxc.helpers.args import DisplayDefaultsNotNone - - -def proto_args(parser, parents): - """Define CLI arguments for the Kerberos protocol""" - kerberos_parser = parser.add_parser( - "kerberos", - help="Kerberos user enumeration (no badPwdCount increment)", - parents=parents, - formatter_class=DisplayDefaultsNotNone - ) - - # Basic connection arguments - kerberos_parser.add_argument( - "--port", - type=int, - default=88, - help="Kerberos port (default: 88)" - ) - - kerberos_parser.add_argument( - "-d", - metavar="DOMAIN", - dest="domain", - type=str, - required=True, - help="Domain to enumerate" - ) - - kerberos_parser.add_argument( - "--dc-ip", - dest="kdcHost", - metavar="DC_IP", - help="IP address or FQDN of the Domain Controller (KDC)" - ) - - kerberos_parser.add_argument( - "--users-export", - help="Enumerate domain users and export them to the specified file" - ) - - return parser From bfa88ac82e07ba61332af4b837f387f835df326b Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Sun, 9 Nov 2025 11:54:37 +0100 Subject: [PATCH 21/26] refactor: Remove kerberos.py implementation and related imports --- nxc/protocols/kerberos.py | 445 -------------------------------------- 1 file changed, 445 deletions(-) delete mode 100644 nxc/protocols/kerberos.py diff --git a/nxc/protocols/kerberos.py b/nxc/protocols/kerberos.py deleted file mode 100644 index 618d352c1b..0000000000 --- a/nxc/protocols/kerberos.py +++ /dev/null @@ -1,445 +0,0 @@ -# Built-in imports -import socket -import time -import binascii -import contextlib - -# Third party imports -from impacket.krb5 import constants -from impacket.krb5.kerberosv5 import getKerberosTGT -from impacket.krb5.types import Principal, KerberosException - -# Local library imports -from nxc.connection import connection -from nxc.config import process_secret -from nxc.logger import NXCAdapter -from nxc.helpers.misc import threaded_enumeration -from nxc.protocols.kerberos.kerberosattacks import KerberosUserEnum - -class kerberos(connection): - """ - Kerberos protocol implementation for NetExec. - - This protocol provides Kerberos-specific enumeration and attack capabilities - without requiring LDAP or SMB connections. - """ - - def __init__(self, args, db, host): - self.domain = None - self.kdcHost = None - self.hash = None - self.lmhash = "" - self.nthash = "" - self.aesKey = "" - self.port = 88 - - connection.__init__(self, args, db, host) - - def proto_logger(self): - """Initialize the protocol-specific logger""" - self.logger = NXCAdapter( - extra={ - "protocol": "KRB5", - "host": self.host, - "port": self.port, - "hostname": self.hostname if hasattr(self, "hostname") else self.host, - } - ) - - def create_conn_obj(self): - """Create connection object (minimal for Kerberos - just validate KDC is reachable)""" - try: - # Test if the KDC port is open - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(self.args.timeout if self.args.timeout else 5) - result = sock.connect_ex((self.host, self.port)) - sock.close() - - if result == 0: - self.logger.debug(f"Kerberos port {self.port} is open on {self.host}") - return True - else: - self.logger.fail(f"Kerberos port {self.port} is closed on {self.host}") - return False - - except TimeoutError: - self.logger.fail(f"Connection timeout to {self.host}:{self.port}") - return False - except Exception as e: - self.logger.fail(f"Error connecting to {self.host}:{self.port} - {e}") - return False - - def enum_host_info(self): - """Enumerate basic host information""" - # Set domain from args - if self.args.domain: - self.domain = self.args.domain.upper() - - # Set KDC host (can be different from target) - if self.args.kdcHost: - self.kdcHost = self.args.kdcHost - else: - self.kdcHost = self.host - - # Try to resolve hostname - try: - self.hostname = socket.gethostbyaddr(self.host)[0] - except Exception: - self.hostname = self.host - - self.logger.debug(f"Domain: {self.domain}, KDC: {self.kdcHost}, Hostname: {self.hostname}") - - # Add to database - try: - self.db.add_host( - self.host, - self.hostname, - self.domain, - "Kerberos" - ) - except Exception as e: - self.logger.debug(f"Error adding host to database: {e}") - - def print_host_info(self): - """Print host information""" - self.logger.display( - f"Kerberos KDC (domain:{self.domain}) (hostname:{self.hostname})" - ) - - def login(self): - """ - Override the default login method to handle Kerberos-specific logic. - - For Kerberos protocol: - - If usernames provided WITHOUT passwords: batch enumerate valid usernames - - If usernames AND passwords provided: authenticate normally (call parent login()) - """ - # Check if we have usernames but no passwords - enumeration mode - if self.args.username and not self.args.password: - self.logger.debug("Kerberos enumeration mode: usernames without passwords") - - # Parse all usernames from args (can be files or direct usernames) - usernames = [] - for user_item in self.args.username: - user_item = user_item.strip() - try: - # Try to open as file - with open(user_item) as f: - file_users = [line.strip() for line in f if line.strip()] - usernames.extend(file_users) - self.logger.info(f"Loaded {len(file_users)} usernames from {user_item}") - except FileNotFoundError: - # Not a file, treat as username - usernames.append(user_item) - except Exception as e: - self.logger.debug(f"Error reading file {user_item}: {e}") - usernames.append(user_item) - - # Remove duplicates - usernames = list(set(usernames)) - - if not usernames: - self.logger.fail("No valid usernames to enumerate") - return False - - # Perform enumeration based on count - if len(usernames) == 1: - return self._check_single_user(usernames[0]) - else: - return self._enum_multiple_users(usernames) - else: - # Normal authentication with credentials - use parent's login method - return super().login() - - def plaintext_login(self, domain, username, password): - """ - Authenticate to Kerberos KDC using username and password. - - This method attempts to get a TGT to verify credentials. - """ - self.username = username - self.password = password - self.domain = domain # Try to authenticate with password - try: - self.logger.debug(f"Attempting Kerberos authentication for {domain}\\{username}") - - userName = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) - - tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT( - clientName=userName, - password=password, - domain=domain.upper(), - lmhash=binascii.unhexlify(self.lmhash) if self.lmhash else b"", - nthash=binascii.unhexlify(self.nthash) if self.nthash else b"", - aesKey=self.aesKey if self.aesKey else "", - kdcHost=self.kdcHost - ) - - self.logger.success(f"{domain}\\{username}:{process_secret(password)}") - - # Add credential to database - self.db.add_credential("plaintext", domain, username, password) - - return True - - except KerberosException as e: - error_msg = str(e) - - # Parse common Kerberos errors - if "KDC_ERR_PREAUTH_FAILED" in error_msg: - self.logger.fail(f"{domain}\\{username}:{process_secret(password)} (invalid credentials)") - elif "KDC_ERR_CLIENT_REVOKED" in error_msg: - self.logger.fail(f"{domain}\\{username}:{process_secret(password)} (account disabled)") - elif "KDC_ERR_C_PRINCIPAL_UNKNOWN" in error_msg: - self.logger.fail(f"{domain}\\{username}:{process_secret(password)} (user does not exist)") - else: - self.logger.fail(f"{domain}\\{username}:{process_secret(password)} ({error_msg})") - - return False - - except Exception as e: - self.logger.fail(f"{domain}\\{username}:{process_secret(password)} (error: {e})") - return False - - def hash_login(self, domain, username, ntlm_hash): - """Authenticate to Kerberos KDC using username and NTLM hash.""" - self.username = username - self.domain = domain - - # Parse NTLM hash - lmhash = "" - nthash = "" - - if ":" in ntlm_hash: - lmhash, nthash = ntlm_hash.split(":") - else: - nthash = ntlm_hash - - self.lmhash = lmhash - self.nthash = nthash - self.hash = ntlm_hash - - try: - self.logger.debug(f"Attempting Kerberos authentication for {domain}\\{username} with NTLM hash") - - userName = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) - - tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT( - clientName=userName, - password="", - domain=domain.upper(), - lmhash=binascii.unhexlify(lmhash) if lmhash else b"", - nthash=binascii.unhexlify(nthash) if nthash else b"", - aesKey="", - kdcHost=self.kdcHost - ) - - self.logger.success(f"{domain}\\{username}:{process_secret(ntlm_hash)}") - - # Add credential to database - self.db.add_credential("hash", domain, username, ntlm_hash) - - return True - - except KerberosException as e: - error_msg = str(e) - - if "KDC_ERR_PREAUTH_FAILED" in error_msg: - self.logger.fail(f"{domain}\\{username}:{process_secret(ntlm_hash)} (invalid hash)") - elif "KDC_ERR_CLIENT_REVOKED" in error_msg: - self.logger.fail(f"{domain}\\{username}:{process_secret(ntlm_hash)} (account disabled)") - elif "KDC_ERR_C_PRINCIPAL_UNKNOWN" in error_msg: - self.logger.fail(f"{domain}\\{username}:{process_secret(ntlm_hash)} (user does not exist)") - else: - self.logger.fail(f"{domain}\\{username}:{process_secret(ntlm_hash)} ({error_msg})") - - return False - - except Exception as e: - self.logger.fail(f"{domain}\\{username}:{process_secret(ntlm_hash)} (error: {e})") - return False - - def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False): - """Authenticate using Kerberos with various credential types.""" - self.username = username - self.password = password - self.domain = domain - self.kdcHost = kdcHost if kdcHost else self.kdcHost - self.aesKey = aesKey - - # Parse NTLM hash if provided - lmhash = "" - nthash = "" - - if ntlm_hash: - if ":" in ntlm_hash: - lmhash, nthash = ntlm_hash.split(":") - else: - nthash = ntlm_hash - - self.lmhash = lmhash - self.nthash = nthash - self.hash = ntlm_hash - - try: - self.logger.debug(f"Attempting Kerberos authentication for {domain}\\{username}") - - userName = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) - - tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT( - clientName=userName, - password=password, - domain=domain.upper(), - lmhash=binascii.unhexlify(lmhash) if lmhash else b"", - nthash=binascii.unhexlify(nthash) if nthash else b"", - aesKey=aesKey if aesKey else "", - kdcHost=self.kdcHost - ) - - # Determine what credential was used - if aesKey: - cred_type = "aesKey" - cred_value = aesKey - elif nthash: - cred_type = "hash" - cred_value = ntlm_hash - else: - cred_type = "plaintext" - cred_value = password - - self.logger.success(f"{domain}\\{username}:{process_secret(cred_value)}") - - # Add credential to database - self.db.add_credential(cred_type, domain, username, cred_value) - - return True - - except KerberosException as e: - error_msg = str(e) - cred_value = aesKey or ntlm_hash or password - - if "KDC_ERR_PREAUTH_FAILED" in error_msg: - self.logger.fail(f"{domain}\\{username}:{process_secret(cred_value)} (invalid credentials)") - elif "KDC_ERR_CLIENT_REVOKED" in error_msg: - self.logger.fail(f"{domain}\\{username}:{process_secret(cred_value)} (account disabled)") - elif "KDC_ERR_C_PRINCIPAL_UNKNOWN" in error_msg: - self.logger.fail(f"{domain}\\{username}:{process_secret(cred_value)} (user does not exist)") - else: - self.logger.fail(f"{domain}\\{username}:{process_secret(cred_value)} ({error_msg})") - - return False - - except Exception as e: - cred_value = aesKey or ntlm_hash or password - self.logger.fail(f"{domain}\\{username}:{process_secret(cred_value)} (error: {e})") - return False - - def _check_single_user(self, username): - """ - Check if a single username is valid via Kerberos AS-REQ request. - This checks user existence without triggering badPwdCount. - """ - kerberos_enum = KerberosUserEnum( - domain=self.domain, - kdcHost=self.kdcHost, - timeout=self.args.timeout if self.args.timeout else 10 - ) - - result = kerberos_enum.check_user_exists(username) - - if result is True: - self.logger.success(f"{self.domain}\\{username}") - # Add to database - with contextlib.suppress(Exception): - self.db.add_host( - self.host, - self.hostname, - self.domain, - "Kerberos" - ) - return True - elif result == "ACCOUNT_DISABLED": - self.logger.highlight(f"{self.domain}\\{username} (disabled)") - return True - elif result is False: - # Only show invalid usernames in debug mode during enumeration - self.logger.debug(f"{self.domain}\\{username} (invalid)") - return False - else: - self.logger.debug(f"{self.domain}\\{username} (error: {result})") - return False - - def _enum_multiple_users(self, usernames): - """ - Enumerate valid domain usernames via Kerberos AS-REQ requests. - This checks user existence without triggering badPwdCount. - """ - self.logger.display( - f"Starting Kerberos user enumeration with {len(usernames)} username(s)" - ) - - # Use the threaded enumeration helper - results = self._check_username_batch(usernames) - - # Aggregate results - valid_users = [r["username"] for r in results if r["status"] == "valid"] - invalid_users = [r["username"] for r in results if r["status"] == "invalid"] - errors = [r["username"] for r in results if r["status"] == "error"] - - # Summary - self.logger.success( - f"Enumeration complete: {len(valid_users)} valid, {len(invalid_users)} invalid, {len(errors)} errors" - ) - - if valid_users: - self.logger.display(f"Valid usernames: {', '.join(valid_users)}") - - # Save to file if requested - if hasattr(self.args, "users_export") and self.args.users_export: - output_file = self.args.users_export - try: - with open(output_file, "w") as f: - f.write("\n".join(valid_users)) - self.logger.success(f"Valid usernames saved to {output_file}") - except Exception as e: - self.logger.fail(f"Error saving valid usernames: {e}") - - return len(valid_users) > 0 - - @threaded_enumeration(items_param="usernames", progress_threshold=100) - def _check_username_batch(self, usernames): - """ - Check a single username via Kerberos AS-REQ. - This method is decorated to run concurrently for multiple usernames. - The number of threads is automatically determined from self.args.threads (--threads CLI argument). - - Args: - usernames: Single username to check (despite plural name, decorator handles iteration) - - Returns: - dict: {"username": str, "status": "valid"|"invalid"|"error"} - """ - kerberos_enum = KerberosUserEnum( - domain=self.domain, - kdcHost=self.kdcHost, - timeout=self.args.timeout if self.args.timeout else 10 - ) - - # Add delay if requested (for stealth/rate limiting) - if hasattr(self.args, "delay") and self.args.delay > 0: - time.sleep(self.args.delay) - - result = kerberos_enum.check_user_exists(usernames) - - if result is True: - self.logger.highlight(f"[+] {usernames}") - return {"username": usernames, "status": "valid"} - elif result == "ACCOUNT_DISABLED": - self.logger.highlight(f"[+] {usernames} (disabled)") - return {"username": usernames, "status": "valid", "disabled": True} - elif result is False: - self.logger.debug(f"[-] {usernames}") - return {"username": usernames, "status": "invalid"} - else: - self.logger.error(f"[!] {usernames}: {result}") - return {"username": usernames, "status": "error", "error": result} From 694e247abebc7f112aa7de57c0f485792861108d Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Sun, 9 Nov 2025 14:34:07 +0100 Subject: [PATCH 22/26] refactor: Add Kerberos user enumeration inside `smb` protocol --- nxc/connection.py | 19 +- nxc/protocols/smb.py | 318 ++++++++++++++++++++++++---------- nxc/protocols/smb/kerberos.py | 235 ++++++++++++++++++++++--- 3 files changed, 449 insertions(+), 123 deletions(-) diff --git a/nxc/connection.py b/nxc/connection.py index 729ff4e682..e923a0a956 100755 --- a/nxc/connection.py +++ b/nxc/connection.py @@ -401,7 +401,12 @@ def parse_credentials(self): username.append(username_single.strip()) owned.append(False) else: - if "\\" in user: + # Check if user looks like a file path but doesn't exist + if "/" in user or "\\" in user: + if not isfile(user): + self.logger.warning(f"File not found: {user} - treating as username") + + if "\\" in user and len(user.split("\\")) == 2: domain_single, username_single = user.split("\\") else: domain_single = self.args.domain if hasattr(self.args, "domain") and self.args.domain is not None else self.domain @@ -423,6 +428,11 @@ def parse_credentials(self): self.logger.error("You can ignore non UTF-8 characters with the option '--ignore-pw-decoding'") sys.exit(1) else: + # Check if password looks like a file path but doesn't exist + if "/" in password or "\\" in password: + if not isfile(password): + self.logger.warning(f"File not found: {password} - treating as password") + secret.append(password) cred_type.append("plaintext") @@ -571,6 +581,13 @@ def login(self): if not (username[0] or secret[0] or domain[0]): return False + # If no secrets provided, add None to allow authentication attempts (useful for user enumeration) + if len(secret) == 0 and len(username) > 0: + self.logger.debug("No secrets provided, adding None to allow authentication attempts") + secret = [None] + cred_type = ["plaintext"] + data = [None] + if not self.args.no_bruteforce: for secr_index, secr in enumerate(secret): for user_index, user in enumerate(username): diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 4d7f17bdea..fb48c2d75d 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -1,3 +1,4 @@ +# Standard library imports import ntpath import binascii import os @@ -6,6 +7,12 @@ import ipaddress from Cryptodome.Hash import MD4 from textwrap import dedent +from time import time, ctime, sleep +from traceback import format_exc +import contextlib + +# External library imports +from termcolor import colored from impacket.smbconnection import SMBConnection, SessionError from impacket.smb import SMB_DIALECT @@ -29,6 +36,7 @@ from impacket.dcerpc.v5.samr import SID_NAME_USE from impacket.dcerpc.v5.dtypes import MAXIMUM_ALLOWED from impacket.krb5.ccache import CCache + from impacket.krb5.kerberosv5 import SessionKeyDecryptionError, getKerberosTGT from impacket.krb5.types import KerberosException, Principal from impacket.krb5 import constants @@ -38,13 +46,20 @@ from impacket.smb3structs import FILE_SHARE_WRITE, FILE_SHARE_DELETE, SMB2_0_IOCTL_IS_FSCTL from impacket.dcerpc.v5 import tsts as TSTS +from dploot.triage.vaults import VaultsTriage +from dploot.triage.browser import BrowserTriage, LoginData, GoogleRefreshToken, Cookie +from dploot.triage.credentials import CredentialsTriage +from dploot.lib.target import Target +from dploot.triage.sccm import SCCMTriage, SCCMCred, SCCMSecret, SCCMCollection + +# Local library imports from nxc.config import process_secret, host_info_colors, check_guest_account from nxc.connection import connection, sem, requires_admin, dcom_FirewallChecker from nxc.helpers.misc import gen_random_string, validate_ntlm from nxc.logger import NXCAdapter from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, get_domain_backup_key, upgrade_to_dploot_connection from nxc.protocols.smb.firefox import FirefoxCookie, FirefoxData, FirefoxTriage -from nxc.protocols.smb.kerberos import kerberos_login_with_S4U +from nxc.protocols.smb.kerberos import kerberos_login_with_S4U, kerberos_asreq_user_enum from nxc.protocols.smb.wmiexec import WMIEXEC from nxc.protocols.smb.atexec import TSCH_EXEC from nxc.protocols.smb.smbexec import SMBEXEC @@ -60,17 +75,6 @@ from nxc.helpers.misc import detect_if_ip from nxc.protocols.ldap.resolution import LDAPResolution -from dploot.triage.vaults import VaultsTriage -from dploot.triage.browser import BrowserTriage, LoginData, GoogleRefreshToken, Cookie -from dploot.triage.credentials import CredentialsTriage -from dploot.lib.target import Target -from dploot.triage.sccm import SCCMTriage, SCCMCred, SCCMSecret, SCCMCollection - -from time import time, ctime, sleep -from traceback import format_exc -from termcolor import colored -import contextlib - smb_share_name = gen_random_string(5).upper() smb_error_status = [ @@ -330,95 +334,40 @@ def print_host_info(self): return self.host, self.hostname, self.targetDomain - def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False): - self.logger.debug(f"KDC set to: {kdcHost}") - # Re-connect since we logged off - self.create_conn_obj() - lmhash = "" - nthash = "" + # ============================================================ + # Public Authentication Methods (called by connection.py) + # ============================================================ - try: - self.password = password - self.username = username - # This checks to see if we didn't provide the LM Hash - if ntlm_hash.find(":") != -1: - lmhash, nthash = ntlm_hash.split(":") - self.hash = nthash - else: - nthash = ntlm_hash - self.hash = ntlm_hash - if lmhash: - self.lmhash = lmhash - if nthash: - self.nthash = nthash - - if not all(s == "" for s in [self.nthash, password, aesKey]): - kerb_pass = next(s for s in [self.nthash, password, aesKey] if s) - else: - kerb_pass = "" - self.logger.debug(f"Attempting to do Kerberos Login with useCache: {useCache}") - - tgs = None - if self.args.delegate: - kerb_pass = "" - self.username = self.args.delegate - serverName = Principal(f"cifs/{self.hostname}", type=constants.PrincipalNameType.NT_SRV_INST.value) - tgs = kerberos_login_with_S4U(domain, self.hostname, username, password, nthash, lmhash, aesKey, kdcHost, self.args.delegate, serverName, useCache, no_s4u2proxy=self.args.no_s4u2proxy) - self.logger.debug(f"Got TGS for {self.args.delegate} through S4U") - - self.conn.kerberosLogin(self.username, password, domain, lmhash, nthash, aesKey, kdcHost, useCache=useCache, TGS=tgs) - if "Unix" not in self.server_os: - self.check_if_admin() + def kerberos_login(self, domain: str, username: str, password: str = None, ntlm_hash: str = "", aesKey: str = "", kdcHost: str = "", useCache: bool = False): + """ + Authenticate using Kerberos. - if username == "": - self.username = self.conn.getCredentials()[0] - elif not self.args.delegate: - self.username = username + If --continue-on-success is set with no password and username list, + performs user enumeration via AS-REQ without incrementing badPwdCount. + Otherwise, performs normal Kerberos authentication. - used_ccache = " from ccache" if useCache else f":{process_secret(kerb_pass)}" - if self.args.delegate: - used_ccache = f" through S4U with {username}" + Args: + domain: Target domain + username: Username to authenticate or enumerate + password: Password (None for enumeration mode) + ntlm_hash: NTLM hash for authentication + aesKey: AES key for authentication + kdcHost: KDC host address + useCache: Use Kerberos ticket cache - out = f"{self.domain}\\{self.username}{used_ccache} {self.mark_pwned()}" - self.logger.success(out) + Returns: + bool: True if authentication/enumeration succeeded + """ + # Check if this is user enumeration mode + if self.args.continue_on_success and password is None and not ntlm_hash and not aesKey: + # User enumeration mode: AS-REQ probing without badPwdCount increment + return self._kerberos_user_enum(domain, username, kdcHost) - if not self.args.local_auth and self.username != "" and not self.args.delegate: - add_user_bh(self.username, domain, self.logger, self.config) - if self.admin_privs: - add_user_bh(f"{self.hostname}$", domain, self.logger, self.config) + if password == "": + self.logger.warning("Using blank password will increment badPwdCount") - # check https://github.com/byt3bl33d3r/CrackMapExec/issues/321 - if self.args.continue_on_success and self.signing: - with contextlib.suppress(Exception): - self.conn.logoff() - return True - except SessionKeyDecryptionError: - # success for now, since it's a vulnerability - previously was an error - self.logger.success( - f"{domain}\\{self.username} account vulnerable to asreproast attack", - color="yellow", - ) - return False - except (FileNotFoundError, KerberosException) as e: - self.logger.fail(f"CCache Error: {e}") - return False - except OSError as e: - used_ccache = " from ccache" if useCache else f":{process_secret(kerb_pass)}" - if self.args.delegate: - used_ccache = f" through S4U with {username}" - self.logger.fail(f"{domain}\\{self.username}{used_ccache} {e}") - except (SessionError, Exception) as e: - error, desc = e.getErrorString() - used_ccache = " from ccache" if useCache else f":{process_secret(kerb_pass)}" - if self.args.delegate: - used_ccache = f" through S4U with {username}" - self.logger.fail( - f"{domain}\\{self.username}{used_ccache} {error} {f'({desc})' if self.args.verbose else ''}", - color="magenta" if error in smb_error_status else "red", - ) - if error not in smb_error_status: - self.inc_failed_login(username) - return False + # Normal Kerberos authentication + return self._kerberos_authenticate(domain, username, password, ntlm_hash, aesKey, kdcHost, useCache) def plaintext_login(self, domain, username, password): # Re-connect since we logged off @@ -474,7 +423,7 @@ def plaintext_login(self, domain, username, password): self.logger.fail("Broken Pipe Error while attempting to login") return False - def hash_login(self, domain, username, ntlm_hash): + def hash_login(self, domain: str, username: str, ntlm_hash: str): # Re-connect since we logged off self.create_conn_obj() lmhash = "" @@ -538,6 +487,183 @@ def hash_login(self, domain, username, ntlm_hash): self.logger.fail("Broken Pipe Error while attempting to login") return False + def _kerberos_authenticate(self, domain: str, username: str, password: str, ntlm_hash: str, aesKey: str, kdcHost: str, useCache: bool): + """ + Perform Kerberos authentication to SMB service. + + This is the internal implementation that handles the actual Kerberos + authentication process including S4U delegation if requested. + + Args: + domain: Target domain + username: Username to authenticate + password: Password for authentication + ntlm_hash: NTLM hash for authentication + aesKey: AES key for authentication + kdcHost: KDC host address + useCache: Use Kerberos ticket cache + + Returns: + bool: True if authentication succeeded, False otherwise + """ + # Re-connect since we logged off + self.create_conn_obj() + lmhash = "" + nthash = "" + + try: + self.password = password + self.username = username + # This checks to see if we didn't provide the LM Hash + if ntlm_hash.find(":") != -1: + lmhash, nthash = ntlm_hash.split(":") + self.hash = nthash + else: + nthash = ntlm_hash + self.hash = ntlm_hash + if lmhash: + self.lmhash = lmhash + if nthash: + self.nthash = nthash + + if not all(s == "" for s in [self.nthash, password, aesKey]): + kerb_pass = next(s for s in [self.nthash, password, aesKey] if s) + else: + kerb_pass = "" + self.logger.debug(f"Attempting to do Kerberos Login with useCache: {useCache}") + + tgs = None + if self.args.delegate: + kerb_pass = "" + self.username = self.args.delegate + serverName = Principal(f"cifs/{self.hostname}", type=constants.PrincipalNameType.NT_SRV_INST.value) + tgs = kerberos_login_with_S4U(domain, self.hostname, username, password, nthash, lmhash, aesKey, kdcHost, self.args.delegate, serverName, useCache, no_s4u2proxy=self.args.no_s4u2proxy) + self.logger.debug(f"Got TGS for {self.args.delegate} through S4U") + + self.conn.kerberosLogin(self.username, password, domain, lmhash, nthash, aesKey, kdcHost, useCache=useCache, TGS=tgs) + if "Unix" not in self.server_os: + self.check_if_admin() + + if username == "": + self.username = self.conn.getCredentials()[0] + elif not self.args.delegate: + self.username = username + + used_ccache = " from ccache" if useCache else f":{process_secret(kerb_pass)}" + if self.args.delegate: + used_ccache = f" through S4U with {username}" + + out = f"{self.domain}\\{self.username}{used_ccache} {self.mark_pwned()}" + self.logger.success(out) + + if not self.args.local_auth and self.username != "" and not self.args.delegate: + add_user_bh(self.username, domain, self.logger, self.config) + if self.admin_privs: + add_user_bh(f"{self.hostname}$", domain, self.logger, self.config) + + # check https://github.com/byt3bl33d3r/CrackMapExec/issues/321 + if self.args.continue_on_success and self.signing: + with contextlib.suppress(Exception): + self.conn.logoff() + return True + except SessionKeyDecryptionError: + # success for now, since it's a vulnerability - previously was an error + self.logger.success( + f"{domain}\\{self.username} account vulnerable to asreproast attack", + color="yellow", + ) + return False + except (FileNotFoundError, KerberosException) as e: + self.logger.fail(f"CCache Error: {e}") + return False + except OSError as e: + used_ccache = " from ccache" if useCache else f":{process_secret(kerb_pass)}" + if self.args.delegate: + used_ccache = f" through S4U with {username}" + self.logger.fail(f"{domain}\\{self.username}{used_ccache} {e}") + except (SessionError, Exception) as e: + error, desc = e.getErrorString() + used_ccache = " from ccache" if useCache else f":{process_secret(kerb_pass)}" + if self.args.delegate: + used_ccache = f" through S4U with {username}" + self.logger.fail( + f"{domain}\\{self.username}{used_ccache} {error} {f'({desc})' if self.args.verbose else ''}", + color="magenta" if error in smb_error_status else "red", + ) + if error not in smb_error_status: + self.inc_failed_login(username) + return False + + def _kerberos_user_enum(self, domain: str, username: str, kdcHost: str): + """ + Enumerate if username exists via Kerberos AS-REQ (no badPwdCount increment). + + This is called when --continue-on-success is set with no password. + It uses AS-REQ without preauthentication to probe user existence. + + Args: + domain: Target domain + username: Username to check + kdcHost: KDC host address + + Returns: + bool: True if user exists, False otherwise + """ + kdc_host = kdcHost if kdcHost else self.host + + # Call the pure function from kerberos.py + result = kerberos_asreq_user_enum(domain.upper(), username, kdc_host) + + # Parse result and log appropriately + if result == "valid": + # User exists and is active + self.logger.success(f"{domain}\\{username}") + + # Add to database + try: + self.db.add_credential("plaintext", domain, username, "") + except Exception: + pass + + return True + + elif result == "disabled": + # User exists but disabled + self.logger.highlight(f"{domain}\\{username} (account disabled)") + + # Still add to database as it's a valid username + try: + self.db.add_credential("plaintext", domain, username, "") + except Exception: + pass + + return True + + elif result == "invalid": + # User doesn't exist - only show in debug mode during enumeration + self.logger.debug(f"{domain}\\{username} (user does not exist)") + return False + + elif result == "wrong_realm": + # Wrong domain + self.logger.fail(f"{domain}\\{username} (wrong realm/domain)") + return False + + elif result.startswith("error:"): + # Error occurred + error_msg = result.split(":", 1)[1] + self.logger.warning(f"{domain}\\{username} ({error_msg})") + return False + + else: + # Unknown result + self.logger.debug(f"{domain}\\{username} (unknown result: {result})") + return False + + # ============================================================ + # SMB Connection Management + # ============================================================ + def create_smbv1_conn(self, check=False): self.logger.info(f"Creating SMBv1 connection to {self.host}") try: diff --git a/nxc/protocols/smb/kerberos.py b/nxc/protocols/smb/kerberos.py index 87c64b0da6..3b3424a4b5 100644 --- a/nxc/protocols/smb/kerberos.py +++ b/nxc/protocols/smb/kerberos.py @@ -1,24 +1,51 @@ +# Standard library imports import datetime import struct import random from six import b +# External library imports from pyasn1.codec.der import decoder, encoder from pyasn1.type.univ import noValue -from impacket.krb5.asn1 import AP_REQ, AS_REP, TGS_REQ, Authenticator, TGS_REP, \ - seq_set, seq_set_iter, PA_FOR_USER_ENC, Ticket as TicketAsn1, EncTGSRepPart, \ - PA_PAC_OPTIONS +from impacket.krb5.asn1 import ( + AP_REQ, + AS_REQ, + AS_REP, + TGS_REQ, + Authenticator, + TGS_REP, + seq_set, + seq_set_iter, + PA_FOR_USER_ENC, + Ticket as TicketAsn1, + EncTGSRepPart, + PA_PAC_OPTIONS, +) from impacket.krb5.types import Principal, KerberosTime, Ticket -from impacket.krb5.kerberosv5 import sendReceive, getKerberosTGT +from impacket.krb5.kerberosv5 import sendReceive, getKerberosTGT, KerberosError from impacket.krb5.ccache import CCache from impacket.krb5.crypto import Key, _enctype_table, _HMACMD5 from impacket.krb5 import constants +# Local library imports from nxc.logger import nxc_logger -def kerberos_login_with_S4U(domain, hostname, username, password, nthash, lmhash, aesKey, kdcHost, impersonate, spn, use_cache, no_s4u2proxy=False): +def kerberos_login_with_S4U( + domain: str, + hostname: str, + username: str, + password: str, + nthash: str, + lmhash: str, + aesKey: str, + kdcHost: str, + impersonate: str, + spn: str, + use_cache: bool, + no_s4u2proxy: bool = False, +): my_tgt = None if use_cache: domain, _, tgt, _ = CCache.parseFile(domain, username, f"cifs/{hostname}") @@ -28,9 +55,13 @@ def kerberos_login_with_S4U(domain, hostname, username, password, nthash, lmhash cipher = tgt["cipher"] session_key = tgt["sessionKey"] if my_tgt is None: - principal = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) + principal = Principal( + username, type=constants.PrincipalNameType.NT_PRINCIPAL.value + ) nxc_logger.debug("Getting TGT for user") - tgt, cipher, _, session_key = getKerberosTGT(principal, password, domain, lmhash, nthash, aesKey, kdcHost) + tgt, cipher, _, session_key = getKerberosTGT( + principal, password, domain, lmhash, nthash, aesKey, kdcHost + ) my_tgt = decoder.decode(tgt, asn1Spec=AS_REP())[0] decoded_tgt = my_tgt # Extract the ticket from the TGT @@ -64,7 +95,9 @@ def kerberos_login_with_S4U(domain, hostname, username, password, nthash, lmhash # TGS-REQ PA-TGS-REQ padata AP-REQ Authenticator (includes # TGS authenticator subkey), encrypted with the TGS session # key (Section 5.5.1) - encrypted_encoded_authenticator = cipher.encrypt(session_key, 7, encoded_authenticator, None) + encrypted_encoded_authenticator = cipher.encrypt( + session_key, 7, encoded_authenticator, None + ) ap_req["authenticator"] = noValue ap_req["authenticator"]["etype"] = cipher.enctype @@ -79,13 +112,17 @@ def kerberos_login_with_S4U(domain, hostname, username, password, nthash, lmhash tgs_req["padata"] = noValue tgs_req["padata"][0] = noValue - tgs_req["padata"][0]["padata-type"] = int(constants.PreAuthenticationDataTypes.PA_TGS_REQ.value) + tgs_req["padata"][0]["padata-type"] = int( + constants.PreAuthenticationDataTypes.PA_TGS_REQ.value + ) tgs_req["padata"][0]["padata-value"] = encoded_ap_req # In the S4U2self KRB_TGS_REQ/KRB_TGS_REP protocol extension, a service # requests a service ticket to itself on behalf of a user. The user is # identified to the KDC by the user's name and realm. - client_name = Principal(impersonate, type=constants.PrincipalNameType.NT_PRINCIPAL.value) + client_name = Principal( + impersonate, type=constants.PrincipalNameType.NT_PRINCIPAL.value + ) s4u_byte_array = struct.pack(" str: + """ + Check if a username exists in Active Directory via Kerberos AS-REQ enumeration. + + This function sends a Kerberos AS-REQ (Authentication Service Request) with + no preauthentication data. The KDC's response reveals whether the user exists + without incrementing badPwdCount since no password is provided. + + KDC Response Codes: + - KDC_ERR_PREAUTH_REQUIRED (25): User exists (preauth required) + - KDC_ERR_C_PRINCIPAL_UNKNOWN (6): User does not exist + - KDC_ERR_CLIENT_REVOKED: User account is disabled + - Other errors: Various account/policy issues + + Args: + domain (str): The target Active Directory domain (e.g., 'CORP.LOCAL') + username (str): Username to check (without domain) + kdcHost (str): IP address or FQDN of the KDC (Domain Controller) + + Returns: + str: Status of the user check + - "valid": User exists and is active + - "disabled": User exists but account is disabled + - "invalid": User does not exist + - "wrong_realm": Wrong domain/realm + - "timeout": Connection timeout + - "error:": Other error occurred + """ + try: + # Build the principal name + client_principal = Principal( + username, type=constants.PrincipalNameType.NT_PRINCIPAL.value + ) + + # Build AS-REQ + as_req = AS_REQ() + + # Set domain + as_req["pvno"] = 5 + as_req["msg-type"] = int(constants.ApplicationTagNumbers.AS_REQ.value) + + # Request body + req_body = seq_set(as_req, "req-body") + + # KDC Options - request forwardable and renewable tickets + opts = [] + opts.append(constants.KDCOptions.forwardable.value) + opts.append(constants.KDCOptions.renewable.value) + opts.append(constants.KDCOptions.renewable_ok.value) + req_body["kdc-options"] = constants.encodeFlags(opts) + + # Set client principal + seq_set(req_body, "cname", client_principal.components_to_asn1) + req_body["realm"] = domain + + # Set server principal (krbtgt) + server_principal = Principal( + f"krbtgt/{domain}", type=constants.PrincipalNameType.NT_PRINCIPAL.value + ) + seq_set(req_body, "sname", server_principal.components_to_asn1) + + now = datetime.datetime.utcnow() + + req_body["till"] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) + req_body["rtime"] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) + req_body["nonce"] = random.randint( + 1, 2147483647 + ) # Random 32-bit positive integer + + # Set encryption types - prefer AES + supported_ciphers = ( + constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value, + constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value, + constants.EncryptionTypes.rc4_hmac.value, + ) + seq_set_iter(req_body, "etype", supported_ciphers) + + # No preauthentication data (this is key for enumeration) + # We deliberately don't include PA-DATA to trigger preauth required response + + # Encode and send the request + message = encoder.encode(as_req) + + try: + sendReceive(message, domain, kdcHost) + except KerberosError as e: + # Analyze the error code to determine user status + error_code = e.getErrorCode() + + if error_code == constants.ErrorCodes.KDC_ERR_PREAUTH_REQUIRED.value: + # User exists! (KDC requires preauthentication) + return "valid" + + elif error_code == constants.ErrorCodes.KDC_ERR_C_PRINCIPAL_UNKNOWN.value: + # User does not exist + return "invalid" + + elif error_code == constants.ErrorCodes.KDC_ERR_CLIENT_REVOKED.value: + # User exists but account is disabled + return "disabled" + + elif error_code == constants.ErrorCodes.KDC_ERR_WRONG_REALM.value: + return "wrong_realm" + + else: + # Other Kerberos error + error_msg = ( + constants.ErrorCodes(error_code).name + if hasattr(constants.ErrorCodes, "_value2member_map_") + else str(error_code) + ) + return f"error:krb_{error_msg}" + + # If we get an AS-REP without error, user exists (very rare without preauth) + return "valid" + + except TimeoutError: + return "error:timeout" + except OSError as e: + return f"error:socket_{e}" + except Exception as e: + return f"error:{e}" From d96c6cd53c185d643f790c802d1867cb9e199dd6 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Sun, 9 Nov 2025 14:39:24 +0100 Subject: [PATCH 23/26] refactor: Remove Kerberos command examples from e2e_commands.txt --- tests/e2e_commands.txt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 5cca0b1c33..a44ed727e4 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -222,15 +222,6 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M subnets netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M user-desc netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M whoami netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dump-computers -##### KERBEROS -netexec kerberos TARGET_HOST -d DOMAIN -u LOGIN_USERNAME -netexec kerberos TARGET_HOST -d DOMAIN -u TEST_USER_FILE -netexec kerberos TARGET_HOST -d DOMAIN -u LOGIN_USERNAME --dc-ip DC_IP -netexec kerberos TARGET_HOST -d DOMAIN -u LOGIN_USERNAME -p LOGIN_PASSWORD -netexec kerberos TARGET_HOST -d DOMAIN -u TEST_USER_FILE -p LOGIN_PASSWORD -netexec kerberos TARGET_HOST -d DOMAIN -u TEST_USER_FILE -p TEST_PASSWORD_FILE --no-bruteforce -netexec kerberos TARGET_HOST -d DOMAIN -u TEST_USER_FILE -p TEST_PASSWORD_FILE --no-bruteforce --continue-on-success -netexec kerberos TARGET_HOST -d DOMAIN -u LOGIN_USERNAME ##### WINRM netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS # need an extra space after this command due to regex netexec winrm TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -X ipconfig From 5add541807aeed132979d59ec0480f2740f50f8e Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Sun, 9 Nov 2025 15:56:45 +0100 Subject: [PATCH 24/26] refactor Kerberos user AS-REQ check --- nxc/protocols/smb.py | 63 ++++++++++++++++------------------- nxc/protocols/smb/kerberos.py | 6 ++-- 2 files changed, 31 insertions(+), 38 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index fb48c2d75d..916eede951 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -358,10 +358,10 @@ def kerberos_login(self, domain: str, username: str, password: str = None, ntlm_ Returns: bool: True if authentication/enumeration succeeded """ - # Check if this is user enumeration mode - if self.args.continue_on_success and password is None and not ntlm_hash and not aesKey: + # Check if this is user enumeration mode (no credentials provided) + if password is None and not ntlm_hash and not aesKey and not useCache: # User enumeration mode: AS-REQ probing without badPwdCount increment - return self._kerberos_user_enum(domain, username, kdcHost) + return self._kerberos_user_verification(domain, username, kdcHost) if password == "": self.logger.warning("Using blank password will increment badPwdCount") @@ -526,8 +526,13 @@ def _kerberos_authenticate(self, domain: str, username: str, password: str, ntlm if nthash: self.nthash = nthash - if not all(s == "" for s in [self.nthash, password, aesKey]): - kerb_pass = next(s for s in [self.nthash, password, aesKey] if s) + # Determine which credential is being used for logging + if self.nthash: + kerb_pass = self.nthash + elif password: + kerb_pass = password + elif aesKey: + kerb_pass = aesKey else: kerb_pass = "" self.logger.debug(f"Attempting to do Kerberos Login with useCache: {useCache}") @@ -594,7 +599,7 @@ def _kerberos_authenticate(self, domain: str, username: str, password: str, ntlm self.inc_failed_login(username) return False - def _kerberos_user_enum(self, domain: str, username: str, kdcHost: str): + def _kerberos_user_verification(self, domain: str, username: str, kdcHost: str): """ Enumerate if username exists via Kerberos AS-REQ (no badPwdCount increment). @@ -614,52 +619,42 @@ def _kerberos_user_enum(self, domain: str, username: str, kdcHost: str): # Call the pure function from kerberos.py result = kerberos_asreq_user_enum(domain.upper(), username, kdc_host) - # Parse result and log appropriately - if result == "valid": - # User exists and is active - self.logger.success(f"{domain}\\{username}") - - # Add to database - try: - self.db.add_credential("plaintext", domain, username, "") - except Exception: - pass - - return True - - elif result == "disabled": - # User exists but disabled - self.logger.highlight(f"{domain}\\{username} (account disabled)") - - # Still add to database as it's a valid username - try: - self.db.add_credential("plaintext", domain, username, "") - except Exception: - pass - - return True - - elif result == "invalid": + if result == "invalid": # User doesn't exist - only show in debug mode during enumeration self.logger.debug(f"{domain}\\{username} (user does not exist)") return False - elif result == "wrong_realm": + if result == "wrong_realm": # Wrong domain self.logger.fail(f"{domain}\\{username} (wrong realm/domain)") return False - elif result.startswith("error:"): + if result.startswith("error:"): # Error occurred error_msg = result.split(":", 1)[1] self.logger.warning(f"{domain}\\{username} ({error_msg})") return False + # Parse result and log appropriately + if result == "valid": + # User exists and is active + self.logger.success(f"{domain}\\{username}") + elif result == "disabled": + # User exists but disabled + self.logger.success(f"{domain}\\{username} (account disabled)") else: # Unknown result self.logger.debug(f"{domain}\\{username} (unknown result: {result})") return False + # Add to database + try: + self.db.add_credential("plaintext", domain, username, "") + except Exception: + pass + + return True + # ============================================================ # SMB Connection Management # ============================================================ diff --git a/nxc/protocols/smb/kerberos.py b/nxc/protocols/smb/kerberos.py index 3b3424a4b5..7d31240a8d 100644 --- a/nxc/protocols/smb/kerberos.py +++ b/nxc/protocols/smb/kerberos.py @@ -398,13 +398,11 @@ def kerberos_asreq_user_enum(domain: str, username: str, kdcHost: str) -> str: ) seq_set(req_body, "sname", server_principal.components_to_asn1) - now = datetime.datetime.utcnow() + now = datetime.datetime.now(datetime.timezone.utc) req_body["till"] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) req_body["rtime"] = KerberosTime.to_asn1(now.replace(year=now.year + 1)) - req_body["nonce"] = random.randint( - 1, 2147483647 - ) # Random 32-bit positive integer + req_body["nonce"] = random.getrandbits(31) # Set encryption types - prefer AES supported_ciphers = ( From 2026587d3ff1ffab602fd0d64bff963cb87304c3 Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Sun, 9 Nov 2025 16:09:12 +0100 Subject: [PATCH 25/26] refactor: Remove threaded_enumeration decorator and related imports --- nxc/helpers/misc.py | 166 -------------------------------------------- 1 file changed, 166 deletions(-) diff --git a/nxc/helpers/misc.py b/nxc/helpers/misc.py index 51dbe1140b..7e4546fdf5 100755 --- a/nxc/helpers/misc.py +++ b/nxc/helpers/misc.py @@ -8,172 +8,6 @@ from ipaddress import ip_address from nxc.logger import nxc_logger from time import strftime, gmtime -from concurrent.futures import ThreadPoolExecutor, as_completed -from functools import wraps - - -def threaded_enumeration(items_param="items", max_workers=None, progress_threshold=100, show_progress=True): - """ - Decorator to add multi-threading support to enumeration methods. - - This decorator transforms a sequential enumeration function into a concurrent one, - automatically handling threading, progress bars, and result aggregation. - - Args: - items_param (str): Name of the parameter containing the list of items to enumerate. - Default: "items" - max_workers (int): Maximum number of concurrent threads. If None, uses self.args.threads - from the instance, or defaults to 10 if not available. Default: None - progress_threshold (int): Minimum number of items before showing progress bar. - Set to 0 to always show. Default: 100 - show_progress (bool): Whether to show progress bar. Default: True - - Usage: - # Use explicit max_workers - @threaded_enumeration(items_param="usernames", max_workers=20, progress_threshold=50) - def enumerate_users(self, usernames): - '''Process a single username and return result''' - result = self.check_username(username) - return {"username": username, "valid": result} - - # Or use None to automatically use self.args.threads - @threaded_enumeration(items_param="usernames", progress_threshold=50) - def enumerate_users(self, usernames): - '''Process a single username and return result''' - result = self.check_username(username) - return {"username": username, "valid": result} - - The decorated function should: - 1. Accept an iterable as a parameter (name specified by items_param) - 2. Process ONE item from that iterable - 3. Return a result dict or None - - The decorator will: - 1. Extract the items list from function parameters - 2. Call the function once per item in parallel threads - 3. Show progress bar if enabled and threshold met - 4. Return a list of all results (excluding None values) - - Returns: - list: Aggregated results from all thread executions (None values filtered out) - - Example: - @threaded_enumeration(items_param="users", max_workers=15) - def check_users(self, users): - # This function processes ONE user at a time - # Called automatically by the decorator for each user in the list - is_valid = self.kerberos_check(users) - if is_valid: - self.logger.highlight(f"Valid: {users}") - return {"user": users, "valid": True} - return None - - # Call like normal - the decorator handles threading - results = connection.check_users(["admin", "user1", "user2"]) - # results = [{"user": "admin", "valid": True}, ...] - """ - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - # Get the instance (self) if it's a method - instance = args[0] if args else None - - # Extract the items list from parameters - sig = inspect.signature(func) - bound_args = sig.bind(*args, **kwargs) - bound_args.apply_defaults() - - if items_param not in bound_args.arguments: - raise ValueError( - f"Parameter '{items_param}' not found in function {func.__name__}. " - f"Available parameters: {list(bound_args.arguments.keys())}" - ) - - items = bound_args.arguments[items_param] - - # Validate items is iterable - if not hasattr(items, "__iter__") or isinstance(items, (str, bytes)): - raise TypeError( - f"Parameter '{items_param}' must be an iterable (list, tuple, etc.), " - f"got {type(items).__name__}" - ) - - items_list = list(items) - total = len(items_list) - - if total == 0: - return [] - - results = [] - - # Determine max_workers: use decorator parameter, then self.args.threads, then default 10 - workers = max_workers - if workers is None: - if instance and hasattr(instance, "args") and hasattr(instance.args, "threads"): - workers = instance.args.threads - nxc_logger.debug(f"Using {workers} threads from --threads argument") - else: - workers = 10 - nxc_logger.debug(f"Using default {workers} threads") - - # Determine if we should show progress - use_progress = show_progress and total > progress_threshold - - def process_item(item): - """Process a single item by calling the original function""" - # Create new args with just the single item - new_kwargs = bound_args.arguments.copy() - new_kwargs[items_param] = item - - # Remove 'self' from kwargs if present - new_kwargs.pop("self", None) - - # Call function with instance if it's a method - if instance is not None: - return func(instance, **new_kwargs) - else: - return func(**new_kwargs) - - with ThreadPoolExecutor(max_workers=workers) as executor: - futures = {executor.submit(process_item, item): item for item in items_list} - - if use_progress: - # Import here to avoid circular imports - from rich.progress import Progress - from nxc.console import nxc_console - - with Progress(console=nxc_console) as progress: - task = progress.add_task( - f"[cyan]Processing {total} {items_param}", - total=total - ) - - for future in as_completed(futures): - try: - result = future.result() - if result is not None: - results.append(result) - except Exception as e: - item = futures[future] - nxc_logger.error(f"Error processing {item}: {e}") - finally: - progress.update(task, advance=1) - else: - # No progress bar - just collect results - for future in as_completed(futures): - try: - result = future.result() - if result is not None: - results.append(result) - except Exception as e: - item = futures[future] - nxc_logger.error(f"Error processing {item}: {e}") - - return results - - return wrapper - return decorator - def identify_target_file(target_file): with open(target_file) as target_file_handle: From bcf1ab514e1f48769c9fdcb42859da52abad5a4f Mon Sep 17 00:00:00 2001 From: n3rada <72791564+n3rada@users.noreply.github.com> Date: Sun, 9 Nov 2025 16:18:36 +0100 Subject: [PATCH 26/26] fix `ruff` checking warnings --- nxc/connection.py | 10 ++++------ nxc/helpers/misc.py | 1 + nxc/protocols/smb.py | 6 ++---- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/nxc/connection.py b/nxc/connection.py index e923a0a956..1bb4afe367 100755 --- a/nxc/connection.py +++ b/nxc/connection.py @@ -402,9 +402,8 @@ def parse_credentials(self): owned.append(False) else: # Check if user looks like a file path but doesn't exist - if "/" in user or "\\" in user: - if not isfile(user): - self.logger.warning(f"File not found: {user} - treating as username") + if ("/" in user or "\\" in user) and not isfile(user): + self.logger.warning(f"File not found: {user} - treating as username") if "\\" in user and len(user.split("\\")) == 2: domain_single, username_single = user.split("\\") @@ -429,9 +428,8 @@ def parse_credentials(self): sys.exit(1) else: # Check if password looks like a file path but doesn't exist - if "/" in password or "\\" in password: - if not isfile(password): - self.logger.warning(f"File not found: {password} - treating as password") + if ("/" in password or "\\" in password) and not isfile(password): + self.logger.warning(f"File not found: {password} - treating as password") secret.append(password) cred_type.append("plaintext") diff --git a/nxc/helpers/misc.py b/nxc/helpers/misc.py index 7e4546fdf5..7f2d7a5f4a 100755 --- a/nxc/helpers/misc.py +++ b/nxc/helpers/misc.py @@ -9,6 +9,7 @@ from nxc.logger import nxc_logger from time import strftime, gmtime + def identify_target_file(target_file): with open(target_file) as target_file_handle: for i, line in enumerate(target_file_handle): diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 916eede951..7bc8b8e62e 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -338,7 +338,7 @@ def print_host_info(self): # Public Authentication Methods (called by connection.py) # ============================================================ - def kerberos_login(self, domain: str, username: str, password: str = None, ntlm_hash: str = "", aesKey: str = "", kdcHost: str = "", useCache: bool = False): + def kerberos_login(self, domain: str, username: str, password: str | None = None, ntlm_hash: str = "", aesKey: str = "", kdcHost: str = "", useCache: bool = False): """ Authenticate using Kerberos. @@ -648,10 +648,8 @@ def _kerberos_user_verification(self, domain: str, username: str, kdcHost: str): return False # Add to database - try: + with contextlib.suppress(Exception): self.db.add_credential("plaintext", domain, username, "") - except Exception: - pass return True