From 48eb46a5d46f3a2e89bf8eacc058b1291764533e Mon Sep 17 00:00:00 2001 From: LightRadio Date: Thu, 8 Aug 2024 17:36:30 +0200 Subject: [PATCH] POC ldap3 --- nxc/cli.py | 7 +- nxc/connection.py | 20 + nxc/modules/mssql_priv.py | 28 +- nxc/modules/spider_plus.py | 16 +- nxc/netexec.py | 1 + nxc/parsers/ldap_results.py | 12 +- nxc/protocols/ldap.py | 488 +++++++++++++++--------- nxc/protocols/ldap/ldap3_patch.py | 600 ++++++++++++++++++++++++++++++ nxc/protocols/smb.py | 1 - 9 files changed, 954 insertions(+), 219 deletions(-) create mode 100644 nxc/protocols/ldap/ldap3_patch.py diff --git a/nxc/cli.py b/nxc/cli.py index 40c4a8fd4..ae54b1d9f 100755 --- a/nxc/cli.py +++ b/nxc/cli.py @@ -99,7 +99,12 @@ def gen_cli_args(): kerberos_group.add_argument("--use-kcache", action="store_true", help="Use Kerberos authentication from ccache file (KRB5CCNAME)") kerberos_group.add_argument("--aesKey", metavar="AESKEY", nargs="+", help="AES key to use for Kerberos Authentication (128 or 256 bits)") kerberos_group.add_argument("--kdcHost", metavar="KDCHOST", help="FQDN of the domain controller. If omitted it will use the domain part (FQDN) specified in the target parameter") - + + certificate_group = std_parser.add_argument_group("Certificate", "Options for Certificate authentication") + certificate_group.add_argument("-pfx", metavar="PFX", action="store", default=None, dest="pfx", help=".pfx file for certificate authentication") + certificate_group.add_argument("-key", metavar="KEY", action="store", default=None, dest="key", help=".key file for certificate authentication") + certificate_group.add_argument("-cert", metavar="CERT", action="store", default=None, dest="cert", help=".crt file fertificate authentication") + server_group = std_parser.add_argument_group("Servers", "Options for nxc servers") server_group.add_argument("--server", choices={"http", "https"}, default="https", help="use the selected server") server_group.add_argument("--server-host", type=str, default="0.0.0.0", metavar="HOST", help="IP to bind the server to") diff --git a/nxc/connection.py b/nxc/connection.py index 2cf587de9..d77192e1e 100755 --- a/nxc/connection.py +++ b/nxc/connection.py @@ -136,6 +136,9 @@ def __init__(self, args, db, target): self.username = "" self.kerberos = bool(self.args.kerberos or self.args.use_kcache or self.args.aesKey) self.aesKey = None if not self.args.aesKey else self.args.aesKey[0] + self.pfx = self.args.pfx + self.key = self.args.key + self.cert = self.args.cert self.use_kcache = None if not self.args.use_kcache else self.args.use_kcache self.admin_privs = False self.failed_logins = 0 @@ -217,6 +220,9 @@ def plaintext_login(self, domain, username, password): def hash_login(self, domain, username, ntlm_hash): return + + def schannel_login(self, domain, username="", password="", pfx=None, key=None, cert=None): + return def proto_flow(self): self.logger.debug("Kicking off proto_flow") @@ -518,6 +524,20 @@ def login(self): cred_type = [] data = [] # Arbitrary data needed for the login, e.g. ssh_key + + ## POC, skipping the DB stuff + if self.args.pfx: + domain = self.args.domain + username = self.args.username[0] + return self.schannel_login(domain, username, pfx=self.args.pfx, key=None, cert=None) + + + if self.args.key and self.args.cert: + domain = self.args.domain + username = self.args.username[0] + return self.schannel_login(domain, username, pfx=None, key=self.args.key, cert=self.args.cert) + + if self.args.cred_id: db_domain, db_username, db_owned, db_secret, db_cred_type, db_data = self.query_db_creds() domain.extend(db_domain) diff --git a/nxc/modules/mssql_priv.py b/nxc/modules/mssql_priv.py index 12f8e2657..4d11f9b76 100644 --- a/nxc/modules/mssql_priv.py +++ b/nxc/modules/mssql_priv.py @@ -219,7 +219,6 @@ def update_priv(self, user: User, exec_as=""): """ if self.is_admin_user(user.username): user.is_sysadmin = True - self.context.log.debug(f"Updated {user.username} to is_sysadmin") return True user.dbowner = self.check_dbowner_privesc(exec_as) return user.dbowner @@ -250,15 +249,11 @@ def is_admin(self, exec_as="") -> bool: self.revert_context(exec_as) is_admin = res[0][""] self.context.log.debug(f"IsAdmin Result: {is_admin}") - try: - if int(is_admin): - self.context.log.debug("User is admin!") - self.admin_privs = True - return True - else: - return False - except ValueError: - self.logger.fail(f"Error checking if user is admin, got {is_admin} as response. Expected 0 or 1.") + if is_admin: + self.context.log.debug("User is admin!") + self.admin_privs = True + return True + else: return False def get_databases(self, exec_as="") -> list: @@ -447,15 +442,10 @@ def is_admin_user(self, username) -> bool: """ res = self.query_and_get_output(f"SELECT IS_SRVROLEMEMBER('sysadmin', '{username}')") is_admin = res[0][""] - try: - if is_admin != "NULL" and int(is_admin): - self.admin_privs = True - self.context.log.debug(f"Updated: {username} is admin!") - return True - else: - return False - except ValueError: - self.context.log.fail(f"Error checking if user is admin, got {is_admin} as response. Expected 0 or 1.") + if is_admin: + self.admin_privs = True + return True + else: return False def revert_context(self, exec_as): diff --git a/nxc/modules/spider_plus.py b/nxc/modules/spider_plus.py index 6697c3327..aaa18637e 100755 --- a/nxc/modules/spider_plus.py +++ b/nxc/modules/spider_plus.py @@ -3,6 +3,7 @@ from os.path import abspath, join, split, exists, splitext, getsize, sep from os import makedirs, remove, stat import time +import traceback from nxc.paths import TMP_PATH from nxc.protocols.smb.remotefile import RemoteFile from impacket.smb3structs import FILE_READ_DATA @@ -158,8 +159,8 @@ def read_chunk(self, remote_file, chunk_size=CHUNK_SIZE): remote_file.__smbConnection = self.smb.conn return self.read_chunk(remote_file) - except Exception as e: - self.logger.exception(e) + except Exception: + traceback.print_exc() break return chunk @@ -213,13 +214,13 @@ def spider_shares(self): # Start the spider at the root of the share folder self.results[share_name] = {} self.spider_folder(share_name, "") - except SessionError as e: - self.logger.exception(e) + except SessionError: + traceback.print_exc() self.logger.fail("Got a session error while spidering.") self.reconnect() except Exception as e: - self.logger.exception(e) + traceback.print_exc() self.logger.fail(f"Error enumerating shares: {e!s}") # Save the metadata. @@ -254,7 +255,7 @@ def spider_folder(self, share_name, folder): # Check file-dir exclusion filter. if any(d in next_filedir.lower() for d in self.exclude_filter): self.logger.info(f'The {result_type} "{next_filedir}" has been excluded') - self.stats[f"num_{result_type}s_filtered"] += 1 + self.stats[f"{result_type}s_filtered"] += 1 continue if result_type == "folder": @@ -411,7 +412,8 @@ def print_stats(self): self.logger.display(f"Total folders found: {num_folders}") num_folders_filtered = self.stats.get("num_folders_filtered", 0) if num_folders_filtered: - self.logger.display(f"Folders Filtered: {num_folders_filtered}") + num_filtered_folders = len(num_folders_filtered) + self.logger.display(f"Folders Filtered: {num_filtered_folders}") # File statistics. num_files = self.stats.get("num_files", 0) diff --git a/nxc/netexec.py b/nxc/netexec.py index 3c66572f1..35d0b38c1 100755 --- a/nxc/netexec.py +++ b/nxc/netexec.py @@ -142,6 +142,7 @@ def main(): protocol_object = getattr(p_loader.load_protocol(protocol_path), args.protocol) nxc_logger.debug(f"Protocol Object: {protocol_object}, type: {type(protocol_object)}") + nxc_logger.debug(f"Protocol Object dir: {dir(protocol_object)}") protocol_db_object = p_loader.load_protocol(protocol_db_path).database nxc_logger.debug(f"Protocol DB Object: {protocol_db_object}") diff --git a/nxc/parsers/ldap_results.py b/nxc/parsers/ldap_results.py index 439e240ac..2746398d5 100644 --- a/nxc/parsers/ldap_results.py +++ b/nxc/parsers/ldap_results.py @@ -1,14 +1,16 @@ -from impacket.ldap import ldapasn1 as ldapasn1_impacket - def parse_result_attributes(ldap_response): parsed_response = [] for entry in ldap_response: # SearchResultReferences may be returned - if not isinstance(entry, ldapasn1_impacket.SearchResultEntry): + if entry["type"] != "searchResEntry": continue attribute_map = {} for attribute in entry["attributes"]: - val = [str(val) for val in attribute["vals"].components] - attribute_map[str(attribute["type"])] = val if len(val) > 1 else val[0] + if "description" in attribute: + attribute_map[str(attribute)] = "" if entry['attributes'][attribute] == [] else str(entry['attributes'][attribute][0]) + elif "pwdLastSet" in attribute: + attribute_map[str(attribute)] = str(entry['attributes'][attribute]) + else: + attribute_map[str(attribute)] = entry['attributes'][attribute] 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 59af5b9b9..990c4fab5 100644 --- a/nxc/protocols/ldap.py +++ b/nxc/protocols/ldap.py @@ -3,6 +3,8 @@ import hashlib import hmac import os +import ldap3 +import re import socket from binascii import hexlify from datetime import datetime, timedelta @@ -26,7 +28,7 @@ from impacket.krb5 import constants from impacket.krb5.kerberosv5 import getKerberosTGS, SessionKeyDecryptionError from impacket.krb5.types import Principal, KerberosException -from impacket.ldap import ldap as ldap_impacket +from nxc.protocols.ldap import ldap3_patch from impacket.ldap import ldapasn1 as ldapasn1_impacket from impacket.ldap.ldap import LDAPFilterSyntaxError from impacket.smb import SMB_DIALECT @@ -137,6 +139,9 @@ def __init__(self, args, db, host): self.ldapConnection = None self.lmhash = "" self.nthash = "" + self.pfx = None + self.key = None + self.cert = None self.baseDN = "" self.target = "" self.targetDomain = "" @@ -167,9 +172,9 @@ def get_ldap_info(self, host): ldap_url = f"{proto}://{host}" self.logger.info(f"Connecting to {ldap_url} with no baseDN") try: - ldap_connection = ldap_impacket.LDAPConnection(ldap_url, dstIp=self.host) - if ldap_connection: - self.logger.debug(f"ldap_connection: {ldap_connection}") + self.ldap_connection = ldap3_patch.LDAPConnection(ldap_url, dstIp=self.host) + if self.ldap_connection: + self.logger.debug(f"ldap_connection: {self.ldap_connection}") except SysCallError as e: if proto == "ldaps": self.logger.fail(f"LDAPs connection to {ldap_url} failed - {e}") @@ -179,32 +184,31 @@ def get_ldap_info(self, host): self.logger.fail(f"LDAP connection to {ldap_url} failed: {e}") exit(1) - resp = ldap_connection.search( - scope=ldapasn1_impacket.Scope("baseObject"), + self.ldap_connection.ldap_connection.search( + search_scope=ldap3.BASE, attributes=["defaultNamingContext", "dnsHostName"], - sizeLimit=0, + search_base='', + search_filter='(objectClass=*)', ) - for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: - continue - target = None - target_domain = None - base_dn = None - try: - for attribute in item["attributes"]: - if str(attribute["type"]) == "defaultNamingContext": - base_dn = str(attribute["vals"][0]) - target_domain = sub( + + target = None + target_domain = None + base_dn = None + + try: + base_dn = self.ldap_connection.ldap_connection.entries[0].defaultNamingContext.value + target_domain = sub( ",DC=", ".", base_dn[base_dn.lower().find("dc="):], flags=I, )[3:] - if str(attribute["type"]) == "dnsHostName": - target = str(attribute["vals"][0]) - except Exception as e: - self.logger.debug("Exception:", exc_info=True) - self.logger.info(f"Skipping item, cannot process due to error {e}") + target = self.ldap_connection.ldap_connection.entries[0].dnsHostName.value + + except Exception as e: + self.logger.debug("Exception:", exc_info=True) + self.logger.info(f"Skipping item, cannot process due to error {e}") + except OSError: return [None, None, None] self.logger.debug(f"Target: {target}; target_domain: {target_domain}; base_dn: {base_dn}") @@ -238,18 +242,14 @@ def get_os_arch(self): return 0 def get_ldap_username(self): - extended_request = ldapasn1_impacket.ExtendedRequest() - extended_request["requestName"] = "1.3.6.1.4.1.4203.1.11.3" # whoami - - response = self.ldapConnection.sendReceive(extended_request) - for message in response: - search_result = message["protocolOp"].getComponent() - if search_result["resultCode"] == ldapasn1_impacket.ResultCode("success"): - response_value = search_result["responseValue"] - if response_value.hasValue(): - value = response_value.asOctets().decode(response_value.encoding)[2:] - return value.split("\\")[1] - return "" + + who_am_i = self.ldapConnection.extend.standard.who_am_i() + match = re.search(r'u:[^\\]+\\(.+)', who_am_i) + if match: + ldap_username = match.group(1) + return ldap_username + else: + return '' def enum_host_info(self): self.target, self.targetDomain, self.baseDN = self.get_ldap_info(self.host) @@ -353,7 +353,7 @@ def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", proto = "ldaps" if (self.args.gmsa or self.port == 636) else "ldap" ldap_url = f"{proto}://{self.target}" self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [1]") - self.ldapConnection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host) + self.ldapConnection = ldap3_patch.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host) self.ldapConnection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache) if self.username == "": self.username = self.get_ldap_username() @@ -389,7 +389,7 @@ def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", color="red", ) return False - except ldap_impacket.LDAPSessionError as e: + except ldap3_patch.LDAPSessionError as e: if str(e).find("strongerAuthRequired") >= 0: # We need to try SSL try: @@ -398,7 +398,7 @@ def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", self.logger.extra["port"] = "636" ldaps_url = f"ldaps://{self.target}" self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [2]") - self.ldapConnection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host) + self.ldapConnection = ldap3_patch.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host) self.ldapConnection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache) if self.username == "": self.username = self.get_ldap_username() @@ -455,7 +455,7 @@ def plaintext_login(self, domain, username, password): proto = "ldaps" if (self.args.gmsa or self.port == 636) else "ldap" ldap_url = f"{proto}://{self.target}" self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [3]") - self.ldapConnection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host) + self.ldapConnection = ldap3_patch.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host) self.ldapConnection.login(self.username, self.password, self.domain, self.lmhash, self.nthash) self.check_if_admin() @@ -467,7 +467,7 @@ def plaintext_login(self, domain, username, password): if self.admin_privs: add_user_bh(f"{self.hostname}$", domain, self.logger, self.config) return True - except ldap_impacket.LDAPSessionError as e: + except ldap3_patch.LDAPSessionError as e: if str(e).find("strongerAuthRequired") >= 0: # We need to try SSL try: @@ -476,7 +476,7 @@ def plaintext_login(self, domain, username, password): self.logger.extra["port"] = "636" ldaps_url = f"ldaps://{self.target}" self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [4]") - self.ldapConnection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host) + self.ldapConnection = ldap3_patch.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host) self.ldapConnection.login(self.username, self.password, self.domain, self.lmhash, self.nthash) self.check_if_admin() @@ -544,7 +544,7 @@ def hash_login(self, domain, username, ntlm_hash): proto = "ldaps" if (self.args.gmsa or self.port == 636) else "ldap" ldaps_url = f"{proto}://{self.target}" self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}") - self.ldapConnection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host) + self.ldapConnection = ldap3_patch.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host) self.ldapConnection.login(self.username, self.password, self.domain, self.lmhash, self.nthash) self.check_if_admin() @@ -557,7 +557,7 @@ def hash_login(self, domain, username, ntlm_hash): if self.admin_privs: add_user_bh(f"{self.hostname}$", domain, self.logger, self.config) return True - except ldap_impacket.LDAPSessionError as e: + except ldap3_patch.LDAPSessionError as e: if str(e).find("strongerAuthRequired") >= 0: try: # We need to try SSL @@ -565,7 +565,7 @@ def hash_login(self, domain, username, ntlm_hash): self.logger.extra["port"] = "636" ldaps_url = f"{proto}://{self.target}" self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}") - self.ldapConnection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host) + self.ldapConnection = ldap3_patch.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host) self.ldapConnection.login(self.username, self.password, self.domain, self.lmhash, self.nthash) self.check_if_admin() @@ -578,7 +578,7 @@ def hash_login(self, domain, username, ntlm_hash): if self.admin_privs: add_user_bh(f"{self.hostname}$", domain, self.logger, self.config) return True - except ldap_impacket.LDAPSessionError as e: + except ldap3_patch.LDAPSessionError as e: error_code = str(e).split()[-2][:-1] self.logger.fail( f"{self.domain}\\{self.username}:{process_secret(nthash)} {ldap_error_status[error_code] if error_code in ldap_error_status else ''}", @@ -598,6 +598,98 @@ def hash_login(self, domain, username, ntlm_hash): self.logger.fail(f"{self.domain}\\{self.username}:{process_secret(self.password)} {'Error connecting to the domain, are you sure LDAP service is running on the target?'} \nError: {e}") return False + def schannel_login(self, domain, username="", password="", pfx=None, key=None, cert=None): + self.username = username + self.password = password + self.pfx = pfx + self.key = key + self.cert = cert + self.domain = domain + + # if self.password == "" and self.args.asreproast: + # hash_tgt = KerberosAttacks(self).get_tgt_asroast(self.username) + # if hash_tgt: + # self.logger.highlight(f"{hash_tgt}") + # with open(self.args.asreproast, "a+") as hash_asreproast: + # hash_asreproast.write(f"{hash_tgt}\n") + # return False + + try: + # Connect to LDAP + self.logger.extra["protocol"] = "LDAPS" if (self.args.gmsa or self.port == 636) else "LDAP" + self.logger.extra["port"] = "636" if (self.args.gmsa or self.port == 636) else "389" + proto = "ldaps" if (self.args.gmsa or self.port == 636) else "ldap" + ldap_url = f"{proto}://{self.target}" + self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [3]") + self.ldapConnection = ldap3_patch.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host) + self.ldapConnection.schannelLogin(self.username, self.domain, self.pfx, self.key, self.cert) + + self.who_am_i = self.ldapConnection.ldap_connection.extend.standard.who_am_i() + self.username = self.who_am_i.split("\\")[-1] + + if not self.who_am_i: + print('Certificate authentication failed') + return False + + self.check_if_admin() + + # Prepare success credential text + self.logger.success(f"{domain}\\{self.username}:{process_secret(self.password)} {self.mark_pwned()}") + + if not self.args.local_auth and self.username != "": + add_user_bh(self.username, self.domain, self.logger, self.config) + if self.admin_privs: + add_user_bh(f"{self.hostname}$", domain, self.logger, self.config) + return True + except ldap3_patch.LDAPSessionError as e: + if str(e).find("strongerAuthRequired") >= 0: + # We need to try SSL + try: + # Connect to LDAPS + self.logger.extra["protocol"] = "LDAPS" + self.logger.extra["port"] = "636" + ldaps_url = f"ldaps://{self.target}" + self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [4]") + self.ldapConnection = ldap3_patch.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host) + self.ldapConnection.schannelLogin(self.username, self.domain, self.pfx, self.key, self.cert) + + self.who_am_i = self.ldapConnection.ldap_connection.extend.standard.who_am_i() + self.username = self.who_am_i.split("\\")[-1] + + if not self.who_am_i: + print('Certificate authentication failed') + return False + + self.check_if_admin() + + # Prepare success credential text + self.logger.success(f"{domain}\\{self.username}:{process_secret(self.password)} {self.mark_pwned()}") + + if not self.args.local_auth and self.username != "": + add_user_bh(self.username, self.domain, self.logger, self.config) + if self.admin_privs: + add_user_bh(f"{self.hostname}$", domain, self.logger, self.config) + return True + except Exception as e: + error_code = str(e).split()[-2][:-1] + self.logger.fail( + f"{self.domain}\\{self.username}:{process_secret(self.password)} {ldap_error_status[error_code] if error_code in ldap_error_status else ''}", + color="magenta" if (error_code in ldap_error_status and error_code != 1) else "red", + ) + self.logger.fail("LDAPS channel binding might be enabled, this is only supported with kerberos authentication. Try using '-k'.") + else: + error_code = str(e).split()[-2][:-1] + self.logger.fail( + f"{self.domain}\\{self.username}:{process_secret(self.password)} {ldap_error_status[error_code] if error_code in ldap_error_status else ''}", + color="magenta" if (error_code in ldap_error_status and error_code != 1) else "red", + ) + if proto == "ldaps": + self.logger.fail("LDAPS channel binding might be enabled, this is only supported with kerberos authentication. Try using '-k'.") + return False + except OSError as e: + self.logger.fail(f"{self.domain}\\{self.username}:{process_secret(self.password)} {'Error connecting to the domain, are you sure LDAP service is running on the target?'} \nError: {e}") + return False + def create_smbv1_conn(self): self.logger.debug("Creating smbv1 connection object") try: @@ -660,32 +752,36 @@ def check_if_admin(self): 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 != "": - for attribute in resp[0][1]: - if str(attribute["type"]) == "objectSid": - sid = self.sid_to_str(attribute["vals"][0]) - self.sid_domain = "-".join(sid.split("-")[:-1]) + if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.pfx != None or (self.key and self.cert != None)) and self.username != "": + for entry in resp: + if 'attributes' in entry and 'objectSid' in entry['attributes']: + if isinstance(entry['attributes']['objectSid'], list): + object_sid_bytes = entry['attributes']['objectSid'][0] + else: + object_sid_bytes = entry['attributes']['objectSid'] + sid = self.sid_to_str(object_sid_bytes) + self.sid_domain = "-".join(sid.split("-")[:-1]) # 2. get all group cn name search_filter = "(|(objectSid=" + self.sid_domain + "-512)(objectSid=" + self.sid_domain + "-544)(objectSid=" + self.sid_domain + "-519)(objectSid=S-1-5-32-549)(objectSid=S-1-5-32-551))" attributes = ["distinguishedName"] resp = self.search(search_filter, attributes, sizeLimit=0) answers = [] - for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: - continue - for attribute in item["attributes"]: - if str(attribute["type"]) == "distinguishedName": - answers.append(str("(memberOf:1.2.840.113556.1.4.1941:=" + attribute["vals"][0] + ")")) + for entry in resp: + # answers.append(str("(memberOf:1.2.840.113556.1.4.1941:=" + entry + ")")) + if 'attributes' in entry and 'distinguishedName' in entry['attributes']: + if isinstance(entry['attributes']['distinguishedName'], list): + distinguished_name = entry['attributes']['distinguishedName'][0] + else: + distinguished_name = entry['attributes']['distinguishedName'] + answers.append(f"(memberOf:1.2.840.113556.1.4.1941:={distinguished_name})") # 3. get member of these groups search_filter = "(&(objectCategory=user)(sAMAccountName=" + self.username + ")(|" + "".join(answers) + "))" - attributes = [""] + attributes = ["*"] resp = self.search(search_filter, attributes, sizeLimit=0) answers = [] for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: - continue if item: self.admin_privs = True @@ -694,27 +790,19 @@ def getUnixTime(self, t): t /= 10000000 return t - def search(self, searchFilter, attributes, sizeLimit=0): + def search(self, search_filter, attributes, sizeLimit=0): try: if self.ldapConnection: - self.logger.debug(f"Search Filter={searchFilter}") + self.logger.debug(f"Search Filter={search_filter}") - # Microsoft Active Directory set an hard limit of 1000 entries returned by any search - paged_search_control = ldapasn1_impacket.SimplePagedResultsControl(criticality=True, size=1000) return self.ldapConnection.search( - searchFilter=searchFilter, + search_filter=search_filter, attributes=attributes, - sizeLimit=sizeLimit, - searchControls=[paged_search_control], + sizeLimit=sizeLimit ) - except ldap_impacket.LDAPSearchError as e: - if e.getErrorString().find("sizeLimitExceeded") >= 0: - # We should never reach this code as we use paged search now - self.logger.fail("sizeLimitExceeded exception caught, giving up and processing the data received") - e.getAnswers() - else: - self.logger.fail(e) - return False + except Exception as e: + self.logger.fail(e) + return False return False def users(self): @@ -745,27 +833,36 @@ def users(self): 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: + if item["type"] != "searchResEntry": continue self.logger.highlight(f"{item['objectName']}") return users = parse_result_attributes(resp) + + # users = [] + # for entry in resp: + # # SearchResultReferences may be returned + # if entry["type"] != "searchResEntry": + # continue + # attribute_map = {} + # for attribute in entry["attributes"]: + # if isinstance(entry['attributes'][attribute], list): + # attribute_map[str(attribute)] = str(entry['attributes'][attribute][0]) if entry['attributes'][attribute] != [] else "" + # else: + # attribute_map[str(attribute)] = str(entry['attributes'][attribute]) + # users.append(attribute_map) + # 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.highlight(f"{'-Username-':<30}{'-Last PW Set-':<20}{'-BadPW-':<8}{'-Description-':<60}") for user in users: - # 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 = "" + pwd_last_set = pwd_last_set[:19] + if pwd_last_set == "1601-01-01 00:00:00": + 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}") + 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 @@ -776,13 +873,13 @@ def groups(self): self.logger.debug(f"Total of records returned {len(resp):d}") for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: + if item["type"] != "searchResEntry": continue name = "" try: for attribute in item["attributes"]: - if str(attribute["type"]) == "name": - name = str(attribute["vals"][0]) + if "name" in item["attributes"]: + name = item["attributes"][attribute] self.logger.highlight(f"{name}") except Exception as e: self.logger.debug("Exception:", exc_info=True) @@ -796,13 +893,13 @@ def dc_list(self): resp = self.search(search_filter, attributes, 0) for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: + if item["type"] != "searchResEntry": continue name = "" try: for attribute in item["attributes"]: - if str(attribute["type"]) == "dNSHostName": - name = str(attribute["vals"][0]) + if "dNSHostName" in item["attributes"]: + name = item["attributes"][attribute] try: ip_address = socket.gethostbyname(name.split(".")[0]) if ip_address is not True and name != "": @@ -870,9 +967,7 @@ def active_users(self): 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") + parsed_pw_last_set = pwd_last_set[:19] if parsed_pw_last_set == "1601-01-01 00:00:00": parsed_pw_last_set = "" @@ -884,7 +979,9 @@ def active_users(self): 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: + # if self.password == "" and self.nthash == "" and self.kerberos is False: + # # can't work because for POC purpose try_credentials() has been bypass => to be modified + if self.password == "" and self.nthash == "" and self.kerberos is False and self.pfx is None and (self.key, self.cert) is None: return False # Building the search filter search_filter = "(&(UserAccountControl:1.2.840.113556.1.4.803:=%d)(!(UserAccountControl:1.2.840.113556.1.4.803:=%d))(!(objectCategory=computer)))" % (UF_DONT_REQUIRE_PREAUTH, UF_ACCOUNTDISABLE) @@ -895,7 +992,7 @@ def asreproast(self): "userAccountControl", "lastLogon", ] - resp = self.search(search_filter, attributes, 0) + resp = self.search(search_filter, attributes, sizeLimit=0) if resp is None: self.logger.highlight("No entries found!") elif resp: @@ -903,7 +1000,7 @@ def asreproast(self): self.logger.display(f"Total of records returned {len(resp):d}") for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: + if item["type"] != "searchResEntry": continue mustCommit = False sAMAccountName = "" @@ -912,18 +1009,21 @@ def asreproast(self): userAccountControl = 0 lastLogon = "N/A" try: - for attribute in item["attributes"]: - if str(attribute["type"]) == "sAMAccountName": - sAMAccountName = str(attribute["vals"][0]) + + attributes = item["attributes"] + + for attribute in attributes.keys(): + if attribute == "sAMAccountName": + sAMAccountName = str(attributes["sAMAccountName"]) mustCommit = True - elif str(attribute["type"]) == "userAccountControl": - userAccountControl = "0x%x" % int(attribute["vals"][0]) - elif str(attribute["type"]) == "memberOf": - memberOf = str(attribute["vals"][0]) - elif str(attribute["type"]) == "pwdLastSet": - pwdLastSet = "" if str(attribute["vals"][0]) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute["vals"][0]))))) - elif str(attribute["type"]) == "lastLogon": - lastLogon = "" if str(attribute["vals"][0]) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute["vals"][0]))))) + elif attribute == "userAccountControl": + userAccountControl = str(attributes["userAccountControl"]) + elif attribute == "memberOf": + memberOf = str(attributes["memberOf"][0]) + elif attribute == "pwdLastSet": + pwdLastSet = "" if str(attributes["pwdLastSet"]) == "1601-01-01 00:00:00+00:00" else str(attributes["pwdLastSet"]) + elif attribute == "lastLogon": + lastLogon = "" if str(attributes["lastLogon"]) == "1601-01-01 00:00:00+00:00" else str(attributes["lastLogon"]) if mustCommit is True: answers.append( [ @@ -952,7 +1052,7 @@ def asreproast(self): def kerberoasting(self): # Building the search filter - searchFilter = "(&(servicePrincipalName=*)(!(objectCategory=computer)))" + search_filter = "(&(servicePrincipalName=*)(!(objectCategory=computer)))" attributes = [ "servicePrincipalName", "sAMAccountName", @@ -961,8 +1061,8 @@ def kerberoasting(self): "userAccountControl", "lastLogon", ] - resp = self.search(searchFilter, attributes, 0) - self.logger.debug(f"Search Filter: {searchFilter}") + resp = self.search(search_filter, attributes, 0) + self.logger.debug(f"Search Filter: {search_filter}") self.logger.debug(f"Attributes: {attributes}") self.logger.debug(f"Response: {resp}") if not resp: @@ -971,7 +1071,7 @@ def kerberoasting(self): answers = [] for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: + if item["type"] != "searchResEntry": continue mustCommit = False sAMAccountName = "" @@ -982,24 +1082,26 @@ def kerberoasting(self): lastLogon = "N/A" delegation = "" try: - for attribute in item["attributes"]: - if str(attribute["type"]) == "sAMAccountName": - sAMAccountName = str(attribute["vals"][0]) + attributes = item["attributes"] + + for attribute in attributes.keys(): + if attribute == "sAMAccountName": + sAMAccountName = str(attributes["sAMAccountName"]) mustCommit = True - elif str(attribute["type"]) == "userAccountControl": - userAccountControl = str(attribute["vals"][0]) + elif attribute == "userAccountControl": + userAccountControl = str(attributes["userAccountControl"]) if int(userAccountControl) & UF_TRUSTED_FOR_DELEGATION: delegation = "unconstrained" elif int(userAccountControl) & UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION: delegation = "constrained" - elif str(attribute["type"]) == "memberOf": - memberOf = str(attribute["vals"][0]) - elif str(attribute["type"]) == "pwdLastSet": - pwdLastSet = "" if str(attribute["vals"][0]) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute["vals"][0]))))) - elif str(attribute["type"]) == "lastLogon": - lastLogon = "" if str(attribute["vals"][0]) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute["vals"][0]))))) - elif str(attribute["type"]) == "servicePrincipalName": - SPNs = [str(spn) for spn in attribute["vals"]] + elif attribute == "memberOf": + memberOf = str(attributes["memberOf"][0]) + elif attribute == "pwdLastSet": + pwdLastSet = "" if str(attributes["pwdLastSet"]) == "1601-01-01 00:00:00+00:00" else str(attributes["pwdLastSet"]) + elif attribute == "lastLogon": + lastLogon = "" if str(attributes["lastLogon"]) == "1601-01-01 00:00:00+00:00" else str(attributes["lastLogon"]) + elif attribute == "servicePrincipalName": + SPNs = [str(spn) for spn in attributes["servicePrincipalName"]] if mustCommit is True: if int(userAccountControl) & UF_ACCOUNTDISABLE: @@ -1075,19 +1177,34 @@ def query(self): self.logger.fail(f"LDAP Filter Syntax Error: {e}") return for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: + if item["type"] != "searchResEntry": continue - self.logger.success(f"Response for object: {item['objectName']}") + self.logger.success(f"Response for object: {item['dn']}") for attribute in item["attributes"]: - attr = f"{attribute['type']}:" - vals = str(attribute["vals"]).replace("\n", "") + attr = f"{attribute}:" + # if "memberOf" in attribute: + # vals = "\n".join(str(val) for val in item["attributes"][attribute]) + if item["attributes"][attribute] == [] or "": + continue + elif "pwdLastSet" in attribute: + vals = str(item["attributes"][attribute])[:19] + if vals == "1601-01-01 00:00:00": + vals = "" + else: + vals = item["attributes"][attribute] + if "SetOf: " in vals: vals = vals.replace("SetOf: ", "") - self.logger.highlight(f"{attr:<20} {vals}") + if isinstance(vals, list): + for val in vals: + self.logger.highlight(f"{attr:<20} {val}") + attr = "" + else: + self.logger.highlight(f"{attr:<20} {vals}") def trusted_for_delegation(self): # Building the search filter - searchFilter = "(userAccountControl:1.2.840.113556.1.4.803:=524288)" + search_filter = "(userAccountControl:1.2.840.113556.1.4.803:=524288)" attributes = [ "sAMAccountName", "pwdLastSet", @@ -1095,13 +1212,13 @@ def trusted_for_delegation(self): "userAccountControl", "lastLogon", ] - resp = self.search(searchFilter, attributes, 0) + resp = self.search(search_filter, attributes, 0) answers = [] self.logger.debug(f"Total of records returned {len(resp):d}") for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: + if item["type"] != "searchResEntry": continue mustCommit = False sAMAccountName = "" @@ -1110,18 +1227,20 @@ def trusted_for_delegation(self): userAccountControl = 0 lastLogon = "N/A" try: - for attribute in item["attributes"]: - if str(attribute["type"]) == "sAMAccountName": - sAMAccountName = str(attribute["vals"][0]) + attributes = item["attributes"] + + for attribute in attributes.keys(): + if attribute == "sAMAccountName": + sAMAccountName = str(attributes["sAMAccountName"]) mustCommit = True - elif str(attribute["type"]) == "userAccountControl": - userAccountControl = "0x%x" % int(attribute["vals"][0]) - elif str(attribute["type"]) == "memberOf": - memberOf = str(attribute["vals"][0]) - elif str(attribute["type"]) == "pwdLastSet": - pwdLastSet = "" if str(attribute["vals"][0]) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute["vals"][0]))))) - elif str(attribute["type"]) == "lastLogon": - lastLogon = "" if str(attribute["vals"][0]) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute["vals"][0]))))) + elif attribute == "userAccountControl": + userAccountControl = str(attributes["userAccountControl"]) + elif attribute == "memberOf": + memberOf = str(attributes["memberOf"][0]) + elif attribute == "pwdLastSet": + pwdLastSet = "" if str(attributes["pwdLastSet"]) == "1601-01-01 00:00:00+00:00" else str(attributes["pwdLastSet"]) + elif attribute == "lastLogon": + lastLogon = "" if str(attributes["lastLogon"]) == "1601-01-01 00:00:00+00:00" else str(attributes["lastLogon"]) if mustCommit is True: answers.append( [ @@ -1138,17 +1257,17 @@ def trusted_for_delegation(self): if len(answers) > 0: self.logger.debug(answers) for value in answers: - self.logger.highlight(value[0]) + self.logger.highlight(value[0]) else: self.logger.fail("No entries found!") def password_not_required(self): # Building the search filter - searchFilter = "(userAccountControl:1.2.840.113556.1.4.803:=32)" + search_filter = "(userAccountControl:1.2.840.113556.1.4.803:=32)" try: - self.logger.debug(f"Search Filter={searchFilter}") + self.logger.debug(f"Search Filter={search_filter}") resp = self.ldapConnection.search( - searchFilter=searchFilter, + search_filter=search_filter, attributes=[ "sAMAccountName", "pwdLastSet", @@ -1158,19 +1277,13 @@ def password_not_required(self): ], sizeLimit=0, ) - except ldap_impacket.LDAPSearchError as e: - if e.getErrorString().find("sizeLimitExceeded") >= 0: - self.logger.debug("sizeLimitExceeded exception caught, giving up and processing the data received") - # We reached the sizeLimit, process the answers we have already and that's it. Until we implement - # paged queries - resp = e.getAnswers() - else: - return False + except Exception as e: + return False answers = [] self.logger.debug(f"Total of records returned {len(resp):d}") for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: + if item["type"] != "searchResEntry": continue mustCommit = False sAMAccountName = "" @@ -1180,20 +1293,22 @@ def password_not_required(self): status = "enabled" lastLogon = "N/A" try: - for attribute in item["attributes"]: - if str(attribute["type"]) == "sAMAccountName": - sAMAccountName = str(attribute["vals"][0]) + attributes = item["attributes"] + + for attribute in attributes.keys(): + if attribute == "sAMAccountName": + sAMAccountName = str(attributes["sAMAccountName"]) mustCommit = True - elif str(attribute["type"]) == "userAccountControl": - if int(attribute["vals"][0]) & 2: + elif attribute == "userAccountControl": + if int(attributes["userAccountControl"]) & 2: status = "disabled" - userAccountControl = f"0x{int(attribute['vals'][0]):x}" - elif str(attribute["type"]) == "memberOf": - memberOf = str(attribute["vals"][0]) - elif str(attribute["type"]) == "pwdLastSet": - pwdLastSet = "" if str(attribute["vals"][0]) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute["vals"][0]))))) - elif str(attribute["type"]) == "lastLogon": - lastLogon = "" if str(attribute["vals"][0]) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute["vals"][0]))))) + userAccountControl = str(attributes["userAccountControl"]) + elif attribute == "memberOf": + memberOf = str(attributes["memberOf"][0]) + elif attribute == "pwdLastSet": + pwdLastSet = "" if str(attributes["pwdLastSet"]) == "1601-01-01 00:00:00+00:00" else str(attributes["pwdLastSet"]) + elif attribute == "lastLogon": + lastLogon = "" if str(attributes["lastLogon"]) == "1601-01-01 00:00:00+00:00" else str(attributes["lastLogon"]) if mustCommit is True: answers.append( [ @@ -1217,7 +1332,7 @@ def password_not_required(self): def admin_count(self): # Building the search filter - searchFilter = "(adminCount=1)" + search_filter = "(adminCount=1)" attributes = [ "sAMAccountName", "pwdLastSet", @@ -1225,12 +1340,12 @@ def admin_count(self): "userAccountControl", "lastLogon", ] - resp = self.search(searchFilter, attributes, 0) + resp = self.search(search_filter, attributes, 0) answers = [] self.logger.debug(f"Total of records returned {len(resp):d}") for item in resp: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: + if item["type"] != "searchResEntry": continue mustCommit = False sAMAccountName = "" @@ -1239,17 +1354,18 @@ def admin_count(self): userAccountControl = 0 lastLogon = "N/A" try: - for attribute in item["attributes"]: - if str(attribute["type"]) == "sAMAccountName": + attributes = item["attributes"] + for attribute in attributes.keys(): + if attribute == "sAMAccountName": sAMAccountName = str(attribute["vals"][0]) mustCommit = True - elif str(attribute["type"]) == "userAccountControl": + elif attribute == "userAccountControl": userAccountControl = "0x%x" % int(attribute["vals"][0]) - elif str(attribute["type"]) == "memberOf": + elif attribute == "memberOf": memberOf = str(attribute["vals"][0]) - elif str(attribute["type"]) == "pwdLastSet": + elif attribute == "pwdLastSet": pwdLastSet = "" if str(attribute["vals"][0]) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute["vals"][0]))))) - elif str(attribute["type"]) == "lastLogon": + elif attribute == "lastLogon": lastLogon = "" if str(attribute["vals"][0]) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute["vals"][0]))))) if mustCommit is True: answers.append( @@ -1275,28 +1391,28 @@ def gmsa(self): self.logger.display("Getting GMSA Passwords") search_filter = "(objectClass=msDS-GroupManagedServiceAccount)" gmsa_accounts = self.ldapConnection.search( - searchFilter=search_filter, + search_filter=search_filter, attributes=[ "sAMAccountName", "msDS-ManagedPassword", "msDS-GroupMSAMembership", ], sizeLimit=0, - searchBase=self.baseDN, + searchBase=self.baseDN ) if gmsa_accounts: self.logger.debug(f"Total of records returned {len(gmsa_accounts):d}") for item in gmsa_accounts: - if isinstance(item, ldapasn1_impacket.SearchResultEntry) is not True: + if item["type"] != "searchResEntry": continue sAMAccountName = "" passwd = "" for attribute in item["attributes"]: - if str(attribute["type"]) == "sAMAccountName": - sAMAccountName = str(attribute["vals"][0]) - if str(attribute["type"]) == "msDS-ManagedPassword": - data = attribute["vals"][0].asOctets() + if "sAMAccountName" in attribute: + sAMAccountName = item["attributes"][attribute] + if "msDS-ManagedPassword" in attribute: + data = item["attributes"][attribute].asOctets() blob = MSDS_MANAGEDPASSWORD_BLOB() blob.fromString(data) currentPassword = blob["CurrentPassword"][:-2] @@ -1328,7 +1444,7 @@ def gmsa_convert_id(self): # getting the gmsa account search_filter = "(objectClass=msDS-GroupManagedServiceAccount)" gmsa_accounts = self.ldapConnection.search( - searchFilter=search_filter, + search_filter=search_filter, attributes=["sAMAccountName"], sizeLimit=0, searchBase=self.baseDN, @@ -1358,7 +1474,7 @@ def gmsa_decrypt_lsa(self): # getting the gmsa account search_filter = "(objectClass=msDS-GroupManagedServiceAccount)" gmsa_accounts = self.ldapConnection.search( - searchFilter=search_filter, + search_filter=search_filter, attributes=["sAMAccountName"], sizeLimit=0, searchBase=self.baseDN, diff --git a/nxc/protocols/ldap/ldap3_patch.py b/nxc/protocols/ldap/ldap3_patch.py new file mode 100644 index 000000000..fa7a453dd --- /dev/null +++ b/nxc/protocols/ldap/ldap3_patch.py @@ -0,0 +1,600 @@ +# Ldap3 implementation for NexExec +# +# Authors: +# LightRadio (@LightxR) +# +# ToDo: +# [x] Finalize the work ;) +# + +import re +import ldap3 +import ssl +import socket +from binascii import unhexlify +import random +import tempfile + +from pyasn1.codec.ber import encoder, decoder +from pyasn1.error import SubstrateUnderrunError +from pyasn1.type.univ import noValue + +from impacket import LOG +from impacket.ldap.ldapasn1 import Filter, Control, SimplePagedResultsControl, ResultCode, Scope, DerefAliases, Operation, \ + KNOWN_CONTROLS, CONTROL_PAGEDRESULTS, NOTIFICATION_DISCONNECT, KNOWN_NOTIFICATIONS, BindRequest, SearchRequest, \ + SearchResultDone, LDAPMessage +from impacket.ntlm import getNTLMSSPType1, getNTLMSSPType3 +from impacket.spnego import SPNEGO_NegTokenInit, SPNEGO_NegTokenResp, TypesMech + +from typing import Tuple +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + pkcs12, +) +from pyasn1.codec.der import decoder, encoder + +__all__ = [ + 'LDAPConnection', 'LDAPFilterSyntaxError', 'LDAPFilterInvalidException', 'LDAPSessionError', 'LDAPSearchError', + 'Control', 'SimplePagedResultsControl', 'ResultCode', 'Scope', 'DerefAliases', 'Operation', + 'CONTROL_PAGEDRESULTS', 'KNOWN_CONTROLS', 'NOTIFICATION_DISCONNECT', 'KNOWN_NOTIFICATIONS', +] + +# https://tools.ietf.org/search/rfc4515#section-3 +DESCRIPTION = r'(?:[a-z][a-z0-9\-]*)' +NUMERIC_OID = r'(?:(?:\d|[1-9]\d+)(?:\.(?:\d|[1-9]\d+))*)' +OID = r'(?:%s|%s)' % (DESCRIPTION, NUMERIC_OID) +OPTIONS = r'(?:(?:;[a-z0-9\-]+)*)' +ATTRIBUTE = r'(%s%s)' % (OID, OPTIONS) +DN = r'(:dn)' +MATCHING_RULE = r'(?::(%s))' % OID + +RE_OPERATOR = re.compile(r'([:<>~]?=)') +RE_ATTRIBUTE = re.compile(r'^%s$' % ATTRIBUTE, re.I) +RE_EX_ATTRIBUTE_1 = re.compile(r'^%s%s?%s?$' % (ATTRIBUTE, DN, MATCHING_RULE), re.I) +RE_EX_ATTRIBUTE_2 = re.compile(r'^(){0}%s?%s$' % (DN, MATCHING_RULE), re.I) + +# LDAP controls +# https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/3c5e87db-4728-4f29-b164-01dd7d7391ea +LDAP_PAGED_RESULT_OID_STRING = "1.2.840.113556.1.4.319" +# https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/f14f3610-ee22-4d07-8a24-1bf1466cba5f +LDAP_SERVER_NOTIFICATION_OID = "1.2.840.113556.1.4.528" + + +class LDAPConnection: + def __init__(self, url, baseDN='', dstIp=None): + """ + LDAPConnection class + + :param string url: + :param string baseDN: + :param string dstIp: + + :return: a LDAP instance, if not raises a LDAPSessionError exception + """ + self._SSL = False + self._dstPort = 0 + self._dstHost = 0 + self._socket = None + self._baseDN = baseDN + self._dstIp = dstIp + + if url.startswith('ldap://'): + self._dstPort = 389 + self._SSL = False + self._dstHost = url[7:] + elif url.startswith('ldaps://'): + self._dstPort = 636 + self._SSL = True + self._dstHost = url[8:] + elif url.startswith('gc://'): + self._dstPort = 3268 + self._SSL = False + self._dstHost = url[5:] + else: + raise LDAPSessionError(errorString="Unknown URL prefix: '%s'" % url) + + # Try to connect + if self._dstIp is not None: + self.targetHost = self._dstIp + else: + self.targetHost = self._dstHost + + if self._SSL is True: + use_ssl = True + tls = ldap3.Tls( + validate=ssl.CERT_NONE, + version=ssl.PROTOCOL_TLSv1_2, + ciphers='ALL:@SECLEVEL=0' + ) + else: + use_ssl = False + tls = None + + self.ldap_server = ldap3.Server( + host=self.targetHost, + port=self._dstPort, + use_ssl=use_ssl, + get_info=ldap3.ALL, + tls=tls + ) + self.ldap_connection = ldap3.Connection(server=self.ldap_server) + self.ldap_connection.bind() + + + def kerberosLogin(self, user, password, domain='', lmhash='', nthash='', aesKey='', kdcHost=None, TGT=None, + TGS=None, useCache=True): + """ + logins into the target system explicitly using Kerberos. Hashes are used if RC4_HMAC is supported. + + :param string user: username + :param string password: password for the user + :param string domain: domain where the account is valid for (required) + :param string lmhash: LMHASH used to authenticate using hashes (password is not used) + :param string nthash: NTHASH used to authenticate using hashes (password is not used) + :param string aesKey: aes256-cts-hmac-sha1-96 or aes128-cts-hmac-sha1-96 used for Kerberos authentication + :param string kdcHost: hostname or IP Address for the KDC. If None, the domain will be used (it needs to resolve tho) + :param struct TGT: If there's a TGT available, send the structure here and it will be used + :param struct TGS: same for TGS. See smb3.py for the format + :param bool useCache: whether or not we should use the ccache for credentials lookup. If TGT or TGS are specified this is False + + :return: True, raises a LDAPSessionError if error. + """ + + if lmhash != '' or nthash != '': + if len(lmhash) % 2: + lmhash = '0' + lmhash + if len(nthash) % 2: + nthash = '0' + nthash + try: # just in case they were converted already + lmhash = unhexlify(lmhash) + nthash = unhexlify(nthash) + except TypeError: + pass + + # Importing down here so pyasn1 is not required if kerberos is not used. + from impacket.krb5.ccache import CCache + from impacket.krb5.asn1 import AP_REQ, Authenticator, TGS_REP, seq_set + from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS + from impacket.krb5 import constants + from impacket.krb5.types import Principal, KerberosTime, Ticket + import datetime + + + if TGT is not None or TGS is not None or aesKey is not None: + useCache = False + + targetName = 'ldap/%s' % self._dstHost + if useCache: + domain, user, TGT, TGS = CCache.parseFile(domain, user, targetName) + + # First of all, we need to get a TGT for the user + userName = Principal(user, type=constants.PrincipalNameType.NT_PRINCIPAL.value) + if TGT is None: + if TGS is None: + tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT(userName, password, domain, lmhash, nthash, + aesKey, kdcHost) + else: + tgt = TGT['KDC_REP'] + cipher = TGT['cipher'] + sessionKey = TGT['sessionKey'] + + if TGS is None: + serverName = Principal(targetName, type=constants.PrincipalNameType.NT_SRV_INST.value) + tgs, cipher, oldSessionKey, sessionKey = getKerberosTGS(serverName, domain, kdcHost, tgt, cipher, + sessionKey) + else: + tgs = TGS['KDC_REP'] + cipher = TGS['cipher'] + sessionKey = TGS['sessionKey'] + + # Let's build a NegTokenInit with a Kerberos REQ_AP + + blob = SPNEGO_NegTokenInit() + + # Kerberos + blob['MechTypes'] = [TypesMech['MS KRB5 - Microsoft Kerberos 5']] + + # Let's extract the ticket from the TGS + tgs = decoder.decode(tgs, asn1Spec=TGS_REP())[0] + ticket = Ticket() + ticket.from_asn1(tgs['ticket']) + + # Now let's build the AP_REQ + apReq = AP_REQ() + apReq['pvno'] = 5 + apReq['msg-type'] = int(constants.ApplicationTagNumbers.AP_REQ.value) + + opts = [] + apReq['ap-options'] = constants.encodeFlags(opts) + seq_set(apReq, 'ticket', ticket.to_asn1) + + authenticator = Authenticator() + authenticator['authenticator-vno'] = 5 + authenticator['crealm'] = domain + seq_set(authenticator, 'cname', userName.components_to_asn1) + now = datetime.datetime.utcnow() + + authenticator['cusec'] = now.microsecond + authenticator['ctime'] = KerberosTime.to_asn1(now) + + encodedAuthenticator = encoder.encode(authenticator) + + # Key Usage 11 + # AP-REQ Authenticator (includes application authenticator + # subkey), encrypted with the application session key + # (Section 5.5.1) + encryptedEncodedAuthenticator = cipher.encrypt(sessionKey, 11, encodedAuthenticator, None) + + apReq['authenticator'] = noValue + apReq['authenticator']['etype'] = cipher.enctype + apReq['authenticator']['cipher'] = encryptedEncodedAuthenticator + + blob['MechToken'] = encoder.encode(apReq) + + request = ldap3.operation.bind.bind_operation(self.ldap_connection.version, ldap3.SASL, user, None, 'GSS-SPNEGO', + blob.getData()) + + if self.ldap_connection.closed: # try to open connection if closed + self.ldap_connection.open(read_server_info=False) + + self.ldap_connection.sasl_in_progress = True + response = self.ldap_connection.post_send_single_response(self.ldap_connection.send('bindRequest', request, None)) + self.ldap_connection.sasl_in_progress = False + if response[0]['result'] != 0: + raise Exception(response) + + self.ldap_connection.bound = True + + + return True + + def login(self, user='', password='', domain='', lmhash='', nthash=''): + """ + logins into the target system + + :param string user: username + :param string password: password for the user + :param string domain: domain where the account is valid for + :param string lmhash: LMHASH used to authenticate using hashes (password is not used) + :param string nthash: NTHASH used to authenticate using hashes (password is not used) + :param string authenticationChoice: type of authentication protocol to use (default NTLM) + + :return: True, raises a LDAPSessionError if error. + """ + + ldap_user = f"{domain}\\{user}" + + ldap_connection_kwargs = {'user': ldap_user, 'raise_exceptions': True, 'authentication': ldap3.NTLM} + if lmhash or nthash: + lmhash = lmhash if lmhash else "aad3b435b51404eeaad3b435b51404ee" + nthash = nthash if nthash else "31d6cfe0d16ae931b73c59d7e0c089c0" + ldap_connection_kwargs['password'] = f"{lmhash}:{nthash}" + else: + ldap_connection_kwargs['password'] = password + + self.ldap_connection = ldap3.Connection(self.ldap_server, **ldap_connection_kwargs, auto_bind=True) + + return True + + def schannelLogin(self, user='', domain='', pfx: str=None, key: rsa.RSAPublicKey=None, cert: x509.Certificate=None ): + """ + logins into the target system + + :param string user: username + :param string domain: domain where the account is valid for + :param string pfx : PFX file used to authenticate + :param string key : KEY file used to authenticate + :param string cert : CERT file used to authenticate + + :return: True, raises a LDAPSessionError if error. + """ + + if pfx: + with open(pfx, "rb") as f: + key, cert = load_pfx(f.read()) + + key_file = tempfile.NamedTemporaryFile(delete=False) + key_file.write(key_to_pem(key)) + key_file.close() + + cert_file = tempfile.NamedTemporaryFile(delete=False) + cert_file.write(cert_to_pem(cert)) + cert_file.close() + + tls = ldap3.Tls(local_private_key_file=key_file.name, local_certificate_file=cert_file.name, validate=ssl.CERT_NONE) + else: + tls = ldap3.Tls(local_private_key_file=key, local_certificate_file=cert, validate=ssl.CERT_NONE) + + ldap_server_kwargs = {'use_ssl': self._SSL, + 'port': self._dstPort, + 'get_info': ldap3.ALL, + 'tls': tls} + + ldapServer = ldap3.Server(self.targetHost, **ldap_server_kwargs) + + ldap_connection_kwargs = dict() + + if self._dstPort == 389: + # StartTLS connection, can bypass channel binding : https://offsec.almond.consulting/bypassing-ldap-channel-binding-with-starttls.html + ldap_connection_kwargs = {'authentication': ldap3.SASL, + 'sasl_mechanism': ldap3.EXTERNAL, + 'auto_bind': ldap3.AUTO_BIND_TLS_BEFORE_BIND} + + self.ldap_connection = ldap3.Connection(ldapServer, **ldap_connection_kwargs) + + if self._dstPort == 636: + + self.ldap_connection.open() + + return True + + + def search(self, searchBase=None, scope=None, derefAliases=None, sizeLimit=0, timeLimit=0, typesOnly=False, + search_filter='(objectClass=*)', attributes=None, searchControls=None, perRecordCallback=None): + + + if searchBase is None: + searchBase = self._baseDN + if scope is None: + scope = ldap3.SUBTREE + if derefAliases is None: + derefAliases = ldap3.DEREF_NEVER + if attributes is None: + attributes = [] + + results = [] + try: + # https://ldap3.readthedocs.io/en/latest/searches.html#the-search-operation + paged_response = True + paged_cookie = None + page_size=1000 + while paged_response == True: + self.ldap_connection.search( + search_base=searchBase, + search_filter=search_filter, + search_scope=scope, + attributes=attributes, + size_limit=sizeLimit, + paged_size=page_size, + paged_cookie=paged_cookie + ) + if "controls" in self.ldap_connection.result.keys(): + if LDAP_PAGED_RESULT_OID_STRING in self.ldap_connection.result["controls"].keys(): + next_cookie = self.ldap_connection.result["controls"][LDAP_PAGED_RESULT_OID_STRING]["value"]["cookie"] + if len(next_cookie) == 0: + paged_response = False + else: + paged_response = True + paged_cookie = next_cookie + else: + paged_response = False + else: + paged_response = False + for entry in self.ldap_connection.response: + results.append(entry) + except ldap3.core.exceptions.LDAPInvalidFilterError as e: + print("Invalid Filter. (ldap3.core.exceptions.LDAPInvalidFilterError)") + except ldap3.core.exceptions.LDAPAttributeError as e: + print("Invalid attribute. (ldap3.core.exceptions.LDAPAttributeError)") + except Exception as e: + raise e + return results + + +## load_pfx(), key_to_pem() and cert_to_pem() functions from Certipy + +def load_pfx( + pfx: bytes, password: bytes = None +) -> Tuple[rsa.RSAPrivateKey, x509.Certificate, None]: + return pkcs12.load_key_and_certificates(pfx, password)[:-1] + +def key_to_pem(key: rsa.RSAPrivateKey) -> bytes: + return key.private_bytes( + Encoding.PEM, PrivateFormat.PKCS8, encryption_algorithm=NoEncryption() + ) + +def cert_to_pem(cert: x509.Certificate) -> bytes: + return cert.public_bytes(Encoding.PEM) + + + + + ## Remaining code from impacket.ldap => to be cleaned/removed + + def _parseFilter(self, filterStr): + try: + filterStr = filterStr.decode() + except AttributeError: + pass + filterList = list(reversed(filterStr)) + searchFilter = self._consumeCompositeFilter(filterList) + if filterList: # we have not consumed the whole filter string + raise LDAPFilterSyntaxError("unexpected token: '%s'" % filterList[-1]) + return searchFilter + + def _consumeCompositeFilter(self, filterList): + try: + c = filterList.pop() + except IndexError: + raise LDAPFilterSyntaxError('EOL while parsing search filter') + if c != '(': # filter must start with a '(' + filterList.append(c) + raise LDAPFilterSyntaxError("unexpected token: '%s'" % c) + + try: + operator = filterList.pop() + except IndexError: + raise LDAPFilterSyntaxError('EOL while parsing search filter') + if operator not in ['!', '&', '|']: # must be simple filter in this case + filterList.extend([operator, c]) + return self._consumeSimpleFilter(filterList) + + filters = [] + while True: + try: + filters.append(self._consumeCompositeFilter(filterList)) + except LDAPFilterSyntaxError: + break + + try: + c = filterList.pop() + except IndexError: + raise LDAPFilterSyntaxError('EOL while parsing search filter') + if c != ')': # filter must end with a ')' + filterList.append(c) + raise LDAPFilterSyntaxError("unexpected token: '%s'" % c) + + return self._compileCompositeFilter(operator, filters) + + def _consumeSimpleFilter(self, filterList): + try: + c = filterList.pop() + except IndexError: + raise LDAPFilterSyntaxError('EOL while parsing search filter') + if c != '(': # filter must start with a '(' + filterList.append(c) + raise LDAPFilterSyntaxError("unexpected token: '%s'" % c) + + filter = [] + while True: + try: + c = filterList.pop() + except IndexError: + raise LDAPFilterSyntaxError('EOL while parsing search filter') + if c == ')': # we pop till we find a ')' + break + elif c == '(': # should be no unencoded parenthesis + filterList.append(c) + raise LDAPFilterSyntaxError("unexpected token: '('") + else: + filter.append(c) + + filterStr = ''.join(filter) + try: + # https://tools.ietf.org/search/rfc4515#section-3 + attribute, operator, value = RE_OPERATOR.split(filterStr, 1) + except ValueError: + raise LDAPFilterInvalidException("invalid filter: '(%s)'" % filterStr) + + return self._compileSimpleFilter(attribute, operator, value) + + @staticmethod + def _compileCompositeFilter(operator, filters): + searchFilter = Filter() + if operator == '!': + if len(filters) != 1: + raise LDAPFilterInvalidException("'not' filter must have exactly one element") + searchFilter['not'].setComponents(*filters) + elif operator == '&': + if len(filters) == 0: + raise LDAPFilterInvalidException("'and' filter must have at least one element") + searchFilter['and'].setComponents(*filters) + elif operator == '|': + if len(filters) == 0: + raise LDAPFilterInvalidException("'or' filter must have at least one element") + searchFilter['or'].setComponents(*filters) + + return searchFilter + + @staticmethod + def _compileSimpleFilter(attribute, operator, value): + searchFilter = Filter() + if operator == ':=': # extensibleMatch + match = RE_EX_ATTRIBUTE_1.match(attribute) or RE_EX_ATTRIBUTE_2.match(attribute) + if not match: + raise LDAPFilterInvalidException("invalid filter attribute: '%s'" % attribute) + attribute, dn, matchingRule = match.groups() + if attribute: + searchFilter['extensibleMatch']['type'] = attribute + if dn: + searchFilter['extensibleMatch']['dnAttributes'] = bool(dn) + if matchingRule: + searchFilter['extensibleMatch']['matchingRule'] = matchingRule + searchFilter['extensibleMatch']['matchValue'] = LDAPConnection._processLdapString(value) + else: + if not RE_ATTRIBUTE.match(attribute): + raise LDAPFilterInvalidException("invalid filter attribute: '%s'" % attribute) + if value == '*' and operator == '=': # present + searchFilter['present'] = attribute + elif '*' in value and operator == '=': # substring + assertions = [LDAPConnection._processLdapString(assertion) for assertion in value.split('*')] + choice = searchFilter['substrings']['substrings'].getComponentType() + substrings = [] + if assertions[0]: + substrings.append(choice.clone().setComponentByName('initial', assertions[0])) + for assertion in assertions[1:-1]: + substrings.append(choice.clone().setComponentByName('any', assertion)) + if assertions[-1]: + substrings.append(choice.clone().setComponentByName('final', assertions[-1])) + searchFilter['substrings']['type'] = attribute + searchFilter['substrings']['substrings'].setComponents(*substrings) + elif '*' not in value: # simple + value = LDAPConnection._processLdapString(value) + if operator == '=': + searchFilter['equalityMatch'].setComponents(attribute, value) + elif operator == '~=': + searchFilter['approxMatch'].setComponents(attribute, value) + elif operator == '>=': + searchFilter['greaterOrEqual'].setComponents(attribute, value) + elif operator == '<=': + searchFilter['lessOrEqual'].setComponents(attribute, value) + else: + raise LDAPFilterInvalidException("invalid filter '(%s%s%s)'" % (attribute, operator, value)) + + return searchFilter + + + @classmethod + def _processLdapString(cls, ldapstr): + def replace_escaped_chars(match): + return chr(int(match.group(1), 16)) # group(1) == "XX" (valid hex) + + escaped_chars = re.compile(r'\\([0-9a-fA-F]{2})') # Capture any sequence of "\XX" (where XX is a valid hex) + return re.sub(escaped_chars, replace_escaped_chars, ldapstr) + + +class LDAPFilterSyntaxError(SyntaxError): + pass + + +class LDAPFilterInvalidException(Exception): + pass + + +class LDAPSessionError(Exception): + """ + This is the exception every client should catch + """ + + def __init__(self, error=0, packet=0, errorString=''): + Exception.__init__(self) + self.error = error + self.packet = packet + self.errorString = errorString + + def getErrorCode(self): + return self.error + + def getErrorPacket(self): + return self.packet + + def getErrorString(self): + return self.errorString + + def __str__(self): + return self.errorString + + +class LDAPSearchError(LDAPSessionError): + def __init__(self, error=0, packet=0, errorString='', answers=None): + LDAPSessionError.__init__(self, error, packet, errorString) + if answers is None: + answers = [] + self.answers = answers + + def getAnswers(self): + return self.answers diff --git a/nxc/protocols/smb.py b/nxc/protocols/smb.py index 6a9186701..e90acf055 100755 --- a/nxc/protocols/smb.py +++ b/nxc/protocols/smb.py @@ -858,7 +858,6 @@ def shares(self): self.logger.highlight(f"{name:<15} {','.join(perms):<15} {remark}") return permissions - @requires_admin def interfaces(self): """ Retrieve the list of network interfaces info (Name, IP Address, Subnet Mask, Default Gateway) from remote Windows registry'