Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions nxc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ 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")

# 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
67 changes: 57 additions & 10 deletions nxc/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@
from os.path import isfile
from threading import BoundedSemaphore, Lock
from functools import wraps
from time import sleep
from time import sleep, monotonic
from ipaddress import ip_address
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 @@ -500,6 +532,8 @@ def try_credentials(self, domain, username, owned, secret, cred_type, data=None)
sleep(value)

with sem:
if spray_abort_all:
raise SprayAbort
if self.over_fail_limit(username):
return False
if cred_type == "plaintext":
Expand Down Expand Up @@ -578,8 +612,21 @@ 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:
round_start = monotonic()
for secr_index, secr in enumerate(secret):
# Pause between password batches so badPwdCount resets before we risk a lockout.
# Sleep only what's left of the window - spraying every user already consumed part of it.
if spray_window and secr_index and secr_index % spray_attempts == 0:
remaining = spray_window - (monotonic() - round_start)
if remaining > 0:
self.logger.info(f"Sleeping {remaining:.0f}s so the lockout counter resets before the next round")
sleep(remaining)
round_start = monotonic()
for user_index, user in enumerate(username):
if self.try_credentials(domain[user_index], user, owned[user_index], secr, cred_type[secr_index], data[secr_index]):
owned[user_index] = True
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 == "775":
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 == "775":
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 == "775":
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 == "775":
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")
spray_window_arg = ldap_parser.add_argument("--spray-window", metavar="SECONDS", dest="spray_window", type=float, default=0, 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=get_conditional_action(_StoreAction), make_required=[spray_window_arg], 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
4 changes: 3 additions & 1 deletion nxc/protocols/smb/proto_args.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from argparse import _StoreTrueAction
from argparse import _StoreTrueAction, _StoreAction
from nxc.helpers.args import DisplayDefaultsNotNone, DefaultTrackingAction, get_conditional_action


Expand All @@ -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")
spray_window_arg = smb_parser.add_argument("--spray-window", metavar="SECONDS", dest="spray_window", type=float, default=0, 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=get_conditional_action(_StoreAction), make_required=[spray_window_arg], 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