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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions nxc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ def gen_cli_args():
print(f"{VERSION} - {CODENAME} - {COMMIT} - {DISTANCE}")
sys.exit(1)

if getattr(args, "spray_attempts", 1) < 1:
parser.error("--spray-attempts must be at least 1")

if getattr(args, "spray_attempts_explicitly_set", False) and not getattr(args, "spray_window", 0):
parser.error("--spray-attempts has no effect without --spray-window; add --spray-window <seconds> (at least the lockout observation window) so attempts are actually paced")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can actually make arguments depend on each other. Take a look at how it's done with --ntds and its logic in the smb arg logic.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to the native make_required pattern like --ntds, so --spray-attempts now requires --spray-window at the argparse level. Kept it one-way on purpose so --spray-window alone is the common case (attempts defaults to 1 = wait after every round), so it works standalone; --spray-attempts doesn’t have anything to gate without a window to pair with it.


# Multiply output_tries by 10 to enable more fine granural control, see exec methods
if hasattr(args, "get_output_tries"):
args.get_output_tries = args.get_output_tries * 10
Expand Down
1 change: 1 addition & 0 deletions nxc/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
host_info_colors = literal_eval(nxc_config.get("nxc", "host_info_colors", fallback=["green", "red", "yellow", "cyan"]))
check_guest_account = nxc_config.getboolean("nxc", "check_guest_account", fallback=False)
display_dc = nxc_config.getboolean("nxc", "display_dc", fallback=True)
abort_on_lockout = int(nxc_config.get("nxc", "abort_on_lockout", fallback=3))

if len(host_info_colors) != 4:
nxc_logger.error("Config option host_info_colors must have 4 values! Using default values.")
Expand Down
75 changes: 66 additions & 9 deletions nxc/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
from dns import resolver, rdatatype
from socket import AF_UNSPEC, SOCK_DGRAM, IPPROTO_IP, AI_CANONNAME, getaddrinfo

from nxc.config import pwned_label
from nxc.config import pwned_label, abort_on_lockout
from nxc.helpers.logger import highlight
from nxc.loaders.moduleloader import ModuleLoader
from nxc.logger import nxc_logger, NXCAdapter
from nxc.console import nxc_console
from nxc.context import Context
from nxc.paths import NXC_PATH
from nxc.protocols.ldap.laps import laps_search
Expand All @@ -28,6 +29,13 @@
fail_lock = Lock()
global_failed_logins = 0
user_failed_logins = {}
lockout_lock = Lock()
global_lockouts = 0
spray_abort_all = False


class SprayAbort(Exception):
pass


def get_host_addr_info(target, force_ipv6, dns_server, dns_tcp, dns_timeout):
Expand Down Expand Up @@ -181,6 +189,8 @@ def __init__(self, args, db, target):
self.proto_flow()
except FileNotFoundError as e:
self.logger.error(f"File not found error on target {target}: {e}")
except SprayAbort:
self.logger.debug("Spray aborted on lockout")
except Exception as e:
if "ERROR_DEPENDENT_SERVICES_RUNNING" in str(e):
self.logger.error(f"Exception while calling proto_flow() on target {target}: {e}")
Expand Down Expand Up @@ -255,14 +265,16 @@ def proto_flow(self):
self.output_filename = os.path.join(base_log_dir, filename_pattern)

self.print_host_info()
if self.login() or (self.username == "" and self.password == "" and self.protocol != "mssql"):
self.logger.debug("Calling command arguments")
self.call_cmd_args()
if self.args.module:
self.load_modules()
self.logger.debug("Calling modules")
self.call_modules()
self.disconnect()
try:
if self.login() or (self.username == "" and self.password == "" and self.protocol != "mssql"):
self.logger.debug("Calling command arguments")
self.call_cmd_args()
if self.args.module:
self.load_modules()
self.logger.debug("Calling modules")
self.call_modules()
finally:
self.disconnect()

def call_cmd_args(self):
"""Calls all the methods specified by the command line arguments
Expand Down Expand Up @@ -340,6 +352,26 @@ def over_fail_limit(self, username):

return False

def register_lockout(self, username):
global global_lockouts, spray_abort_all

if not abort_on_lockout:
return

with lockout_lock:
if spray_abort_all:
raise SprayAbort

global_lockouts += 1
if global_lockouts < abort_on_lockout:
return

answer = nxc_console.input(f"[bold red]\\[!] {global_lockouts} lockout responses detected, would you like to quit? \\[Y/n] [/]")
if answer.strip().lower() in ("y", "yes", ""):
spray_abort_all = True
raise SprayAbort
global_lockouts = 0

Comment thread
NeffIsBack marked this conversation as resolved.
def query_db_creds(self):
"""Queries the database for credentials to be used for authentication.

Expand Down Expand Up @@ -488,6 +520,9 @@ def try_credentials(self, domain, username, owned, secret, cred_type, data=None)
if self.args.continue_on_success and owned:
return False

if self.over_fail_limit(username):
return False

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's duplicate, see below. Or what was the reason for adding that here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, that's redundant with the check inside with sem, removed it.

if self.args.jitter:
jitter = self.args.jitter
if "-" in jitter:
Expand Down Expand Up @@ -578,22 +613,44 @@ def login(self):
if not (username[0] or secret[0] or domain[0]):
return False

# Spray pacing is only exposed by protocols that support it (SMB/LDAP)
spray_window = getattr(self.args, "spray_window", 0)
spray_attempts = getattr(self.args, "spray_attempts", 1)

if not self.args.no_bruteforce:
for secr_index, secr in enumerate(secret):
if spray_abort_all:
raise SprayAbort
for user_index, user in enumerate(username):
if spray_abort_all:
raise SprayAbort

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's overkill (especially two times lol). Just check in try_credentials behind the lock

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, fair (and lol at the double check 🤣) Removed all the loop-level checks and moved a single spray_abort_all check to the top of with sem in try_credentials. The thread that hits the lockout still raises inline; the others just raise on their next attempt behind the lock.

if self.try_credentials(domain[user_index], user, owned[user_index], secr, cred_type[secr_index], data[secr_index]):
owned[user_index] = True
if not self.args.continue_on_success:
return True
# Pause between rounds so badPwdCount resets before the next batch can trip a lockout
completed_batch = (secr_index + 1) % spray_attempts == 0
is_last_round = secr_index == len(secret) - 1
if spray_window and completed_batch and not is_last_round:
self.logger.info(f"Completed {secr_index + 1} password round(s); sleeping {spray_window} second(s) so the lockout counter resets before the next round")
sleep(spray_window)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the upper for loop iterates over secrets (e.g. passwords). Therefore we can just move this between the two loops and just wait every round. That should eliminate the need for checking for "last batch" if we just skip secret index 0.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, since we are bruteforcing potentially hundreds of users per round we should measure the timing since the last check and sleep the remaining time (since we might already have exceeded the throttle window once we tried the last user)

Although we have to make sure to not undercut the window. Thoughts? Maybe check a time per window if that doesn't take too much time?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the sleep to the top of the outer loop and to skip index 0, which drops the last-batch check. It's more timing-aware now: We stamp monotonic() per batch and sleep window - elapsed, so the gap between batches is always at least the full spray_window (never undercut), and if a round already ran past the window we skip the sleep instead of doubling up.

Kept --spray-attempts as the batch size via % spray_attempts rather than sleeping every single round. Lmk if you'd rather just simplify to "wait every round", I think it’s like a two-line change.

else:
if len(username) != len(secret):
self.logger.error("Number provided of usernames and passwords/hashes do not match!")
return False
for user_index, user in enumerate(username):
if spray_abort_all:
raise SprayAbort
if self.try_credentials(domain[user_index], user, owned[user_index], secret[user_index], cred_type[user_index], data[user_index]) and not self.args.continue_on_success:
owned[user_index] = True
if not self.args.continue_on_success:
return True
# Pause between attempts so badPwdCount resets before the next batch can trip a lockout
completed_batch = (user_index + 1) % spray_attempts == 0
is_last_attempt = user_index == len(username) - 1
if spray_window and completed_batch and not is_last_attempt:
self.logger.info(f"Completed {user_index + 1} attempt(s); sleeping {spray_window} second(s) so the lockout counter resets before the next attempt")
sleep(spray_window)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we have only one round of usernames (since we do username=password) I don't see why we would need this here anyway. Or am I missing something?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, no-bruteforce is one password per user, so there are no rounds to pace and badPwdCount never stacks. Removed the pause from that loop entirely.


def mark_pwned(self):
return highlight(f"({pwned_label})" if self.admin_privs else "")
Expand Down
1 change: 1 addition & 0 deletions nxc/data/nxc.conf
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ log_mode = False
host_info_colors = ["green", "red", "yellow", "cyan"]
check_guest_account = False
display_dc = True
abort_on_lockout = 3

[BloodHound]
bh_enabled = False
Expand Down
3 changes: 3 additions & 0 deletions nxc/netexec.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ def main():
if args.jitter and len(targets) > 1:
nxc_logger.highlight(highlight("[!] Jitter is only throttling authentications per target!", "red"))

if getattr(args, "spray_window", 0) and len(targets) > 1:
nxc_logger.highlight(highlight("[!] Lockout-safe spray timing is applied PER TARGET. Spraying multiple DCs of the same domain in parallel can still lock accounts - use a single target or --threads 1.", "red"))

try:
asyncio.run(start_run(protocol_object, args, db, targets))
finally:
Expand Down
24 changes: 24 additions & 0 deletions nxc/protocols/ldap.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,11 @@ def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="",
f"{self.domain}\\{self.username}{used_ccache} {error!s}",
color="magenta" if error in ldap_error_status else "red",
)
if error == "KDC_ERR_CLIENT_REVOKED":
self.inc_failed_login(self.username)
self.register_lockout(self.username)
elif error not in ldap_error_status:
self.inc_failed_login(self.username)
return False
except (KeyError, KerberosException, OSError) as e:
self.logger.fail(
Expand Down Expand Up @@ -422,20 +427,33 @@ def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="",
f"{self.domain}\\{self.username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'} {error!s}",
color="magenta" if error in ldap_error_status else "red",
)
if error == "KDC_ERR_CLIENT_REVOKED":
self.inc_failed_login(self.username)
self.register_lockout(self.username)
elif error not in ldap_error_status:
self.inc_failed_login(self.username)
return False
except Exception as e:
error_code = str(e).split()[-2][:-1]
self.logger.fail(
f"{self.domain}\\{self.username}:{process_secret(self.password)} {ldap_error_status.get(error_code, '')}",
color="magenta" if error_code in ldap_error_status else "red",
)
if error_code in ("775", "KDC_ERR_CLIENT_REVOKED"):
self.inc_failed_login(self.username)
self.register_lockout(self.username)
elif error_code not in ldap_error_status:
self.inc_failed_login(self.username)
return False
else:
error_code = str(e).split()[-2][:-1]
self.logger.fail(
f"{self.domain}\\{self.username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'} {error_code!s}",
color="magenta" if error_code in ldap_error_status else "red",
)
self.inc_failed_login(self.username)
if error_code in ("775", "KDC_ERR_CLIENT_REVOKED"):
self.register_lockout(self.username)
return False

def plaintext_login(self, domain, username, password):
Expand Down Expand Up @@ -513,6 +531,9 @@ def plaintext_login(self, domain, username, password):
f"{self.domain}\\{self.username}:{process_secret(self.password)} {ldap_error_status.get(error_code, '')}",
color="magenta" if (error_code in ldap_error_status and error_code != 1) else "red",
)
self.inc_failed_login(self.username)
if error_code in ("775", "KDC_ERR_CLIENT_REVOKED"):
self.register_lockout(self.username)
Comment on lines +534 to +536

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would NTLM return a "KDC_ERR_CLIENT_REVOKED"?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wouldn't, good catch. That branch parses a numeric LDAP code, so the Kerberos string was dead; the real Kerberos path is handled separately via getErrorString(). Dropped it.

return False
except OSError as e:
self.logger.fail(f"{self.domain}\\{self.username}:{process_secret(self.password)} {'Error connecting to the domain, are you sure LDAP service is running on the target?'} \nError: {e}")
Expand Down Expand Up @@ -607,6 +628,9 @@ def hash_login(self, domain, username, ntlm_hash):
f"{self.domain}\\{self.username}:{process_secret(nthash)} {ldap_error_status.get(error_code, '')}",
color="magenta" if (error_code in ldap_error_status and error_code != 1) else "red",
)
self.inc_failed_login(self.username)
if error_code in ("775", "KDC_ERR_CLIENT_REVOKED"):
self.register_lockout(self.username)
Comment on lines +631 to +633

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix here as above. Also spotted the identical copy-paste in two Kerberos-fallback branches (442/455) that parse numeric codes too, so I cleaned those for consistency.. all four numeric sites are now just == "775".

return False
except OSError as e:
self.logger.fail(f"{self.domain}\\{self.username}:{process_secret(self.password)} {'Error connecting to the domain, are you sure LDAP service is running on the target?'} \nError: {e}")
Expand Down
2 changes: 2 additions & 0 deletions nxc/protocols/ldap/proto_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ def proto_args(parser, parents):
dgroup.add_argument("--simple-bind", action="store_true", help="Use simple bind authentication (no signing/sealing)")
ldap_parser.add_argument("--port", type=int, default=389, action=DefaultTrackingAction, help="LDAP port")
ldap_parser.add_argument("--ldap-timeout", type=int, default=3, help="LDAP connection timeout")
ldap_parser.add_argument("--spray-window", metavar="SECONDS", dest="spray_window", type=float, default=0, action=DefaultTrackingAction, help="seconds to wait between password rounds so the domain badPwdCount resets; set to at least the lockout Observation Window")
ldap_parser.add_argument("--spray-attempts", metavar="N", dest="spray_attempts", type=int, default=1, action=DefaultTrackingAction, help="passwords to try per user before waiting --spray-window (MUST stay below the lockout threshold; default 1 = wait after every password)")
ldap_parser.add_argument("-d", metavar="DOMAIN", dest="domain", type=str, default=None, help="domain to authenticate to")

egroup = ldap_parser.add_argument_group("Retrieve hash on the remote DC", "Options to get hashes from Kerberos")
Expand Down
13 changes: 13 additions & 0 deletions nxc/protocols/smb.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"STATUS_PASSWORD_MUST_CHANGE",
"STATUS_ACCESS_DENIED",
"STATUS_NO_SUCH_FILE",
"STATUS_ACCOUNT_LOCKED_OUT",
"KDC_ERR_CLIENT_REVOKED",
"KDC_ERR_PREAUTH_FAILED",
]
Expand Down Expand Up @@ -491,6 +492,10 @@ def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="",
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 == "KDC_ERR_CLIENT_REVOKED":
self.inc_failed_login(username)
self.register_lockout(username)
return False
if error not in smb_error_status:
self.inc_failed_login(username)
return False
Expand Down Expand Up @@ -546,6 +551,10 @@ def plaintext_login(self, domain, username, password):
f'{domain}\\{self.username}:{process_secret(self.password)} {error} {f"({desc})" if self.args.verbose else ""}',
color="magenta" if error in smb_error_status else "red",
)
if error == "STATUS_ACCOUNT_LOCKED_OUT":
self.inc_failed_login(username)
self.register_lockout(username)
return False
if error in ["STATUS_PASSWORD_MUST_CHANGE", "STATUS_PASSWORD_EXPIRED", "STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT"] and self.args.module == ["change-password"]:
return True
if error not in smb_error_status:
Expand Down Expand Up @@ -613,6 +622,10 @@ def hash_login(self, domain, username, ntlm_hash):
f"{domain}\\{self.username}:{process_secret(self.hash)} {error} {f'({desc})' if self.args.verbose else ''}",
color="magenta" if error in smb_error_status else "red",
)
if error == "STATUS_ACCOUNT_LOCKED_OUT":
self.inc_failed_login(self.username)
self.register_lockout(self.username)
return False
if error in ["STATUS_PASSWORD_MUST_CHANGE", "STATUS_PASSWORD_EXPIRED", "STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT"] and self.args.module == ["change-password"]:
return True
if error not in smb_error_status:
Expand Down
2 changes: 2 additions & 0 deletions nxc/protocols/smb/proto_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ def proto_args(parser, parents):
dgroup.add_argument("--local-auth", action="store_true", help="authenticate locally to each target")

smb_parser.add_argument("--port", type=int, default=445, help="SMB port")
smb_parser.add_argument("--spray-window", metavar="SECONDS", dest="spray_window", type=float, default=0, action=DefaultTrackingAction, help="seconds to wait between password rounds so the domain badPwdCount resets; set to at least the lockout Observation Window")
smb_parser.add_argument("--spray-attempts", metavar="N", dest="spray_attempts", type=int, default=1, action=DefaultTrackingAction, help="passwords to try per user before waiting --spray-window (MUST stay below the lockout threshold; default 1 = wait after every password)")
smb_parser.add_argument("--share", metavar="SHARE", default="C$", help="specify a share")
smb_parser.add_argument("--smb-server-port", default="445", help="specify a server port for SMB", type=int)
smb_parser.add_argument("--no-smbv1", action="store_true", help="Force to disable SMBv1 in connection")
Expand Down
1 change: 1 addition & 0 deletions tests/e2e_commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS --wmi-query
netexec --jitter 2 smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS
netexec --jitter 1-3 smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS
netexec --jitter 2-2 smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS
netexec --spray-window 1 --spray-attempts 1 --continue-on-success smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS
##### SMB Modules
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -L
netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M add-computer -o NAME="BADPC" PASSWORD="Password1"
Expand Down