|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# Impacket - Collection of Python classes for working with network protocols. |
| 3 | +# |
| 4 | +# SECUREAUTH LABS. Copyright (C) 2021 SecureAuth Corporation. All rights reserved. |
| 5 | +# |
| 6 | +# This software is provided under a slightly modified version |
| 7 | +# of the Apache Software License. See the accompanying LICENSE file |
| 8 | +# for more information. |
| 9 | +# |
| 10 | +# Description: |
| 11 | +# Python equivalent to Rubeus tgssub: Substitute an sname or SPN into an existing service ticket |
| 12 | +# New value can be of many forms |
| 13 | +# - (service class only) cifs |
| 14 | +# - (service class with hostname) cifs/service |
| 15 | +# - (service class with hostname and realm) cifs/[email protected] |
| 16 | +# |
| 17 | +# Authors: |
| 18 | +# Charlie Bromberg (@_nwodtuhs) |
| 19 | + |
| 20 | +import logging |
| 21 | +import sys |
| 22 | +import traceback |
| 23 | +import argparse |
| 24 | + |
| 25 | + |
| 26 | +from impacket import version |
| 27 | +from impacket.examples import logger |
| 28 | +from impacket.krb5 import constants, types |
| 29 | +from impacket.krb5.asn1 import TGS_REP, Ticket |
| 30 | +from impacket.krb5.types import Principal |
| 31 | +from impacket.krb5.ccache import CCache, CountedOctetString |
| 32 | +from pyasn1.codec.der import decoder, encoder |
| 33 | + |
| 34 | +def substitute_sname(args): |
| 35 | + ccache = CCache.loadFile(args.inticket) |
| 36 | + cred_number = len(ccache.credentials) |
| 37 | + logging.info('Number of credentials in cache: %d' % cred_number) |
| 38 | + if cred_number > 1: |
| 39 | + raise ValueError("More than one credentials in cache, this is not handled at the moment") |
| 40 | + credential = ccache.credentials[0] |
| 41 | + tgs = credential.toTGS() |
| 42 | + decodedST = decoder.decode(tgs['KDC_REP'], asn1Spec=TGS_REP())[0] |
| 43 | + tgs = ccache.credentials[0].toTGS() |
| 44 | + sname = decodedST['ticket']['sname']['name-string'] |
| 45 | + if len(decodedST['ticket']['sname']['name-string']) == 1: |
| 46 | + logging.debug("Original sname is not formatted as usual (i.e. CLASS/HOSTNAME), automatically filling the substitution service will fail") |
| 47 | + logging.debug("Original sname is: %s" % sname[0]) |
| 48 | + if '/' not in args.altservice: |
| 49 | + raise ValueError("Substitution service must include service class AND name (i.e. CLASS/HOSTNAME@REALM, or CLASS/HOSTNAME)") |
| 50 | + service_class, service_hostname = ('', sname[0]) |
| 51 | + service_realm = decodedST['ticket']['realm'] |
| 52 | + elif len(decodedST['ticket']['sname']['name-string']) == 2: |
| 53 | + service_class, service_hostname = decodedST['ticket']['sname']['name-string'] |
| 54 | + service_realm = decodedST['ticket']['realm'] |
| 55 | + else: |
| 56 | + logging.debug("Original sname is: %s" % '/'.join(sname)) |
| 57 | + raise ValueError("Original sname is not formatted as usual (i.e. CLASS/HOSTNAME), something's wrong here...") |
| 58 | + if '@' in args.altservice: |
| 59 | + new_service_realm = args.altservice.split('@')[1].upper() |
| 60 | + if not '.' in new_service_realm: |
| 61 | + logging.debug("New service realm is not FQDN, you may encounter errors") |
| 62 | + if '/' in args.altservice: |
| 63 | + new_service_hostname = args.altservice.split('@')[0].split('/')[1] |
| 64 | + new_service_class = args.altservice.split('@')[0].split('/')[0] |
| 65 | + else: |
| 66 | + logging.debug("No service hostname in new SPN, using the current one (%s)" % service_hostname) |
| 67 | + new_service_hostname = service_hostname |
| 68 | + new_service_class = args.altservice.split('@')[0] |
| 69 | + else: |
| 70 | + logging.debug("No service realm in new SPN, using the current one (%s)" % service_realm) |
| 71 | + new_service_realm = service_realm |
| 72 | + if '/' in args.altservice: |
| 73 | + new_service_hostname = args.altservice.split('/')[1] |
| 74 | + new_service_class = args.altservice.split('/')[0] |
| 75 | + else: |
| 76 | + logging.debug("No service hostname in new SPN, using the current one (%s)" % service_hostname) |
| 77 | + new_service_hostname = service_hostname |
| 78 | + new_service_class = args.altservice |
| 79 | + if len(service_class) == 0: |
| 80 | + current_service = "%s@%s" % (service_hostname, service_realm) |
| 81 | + else: |
| 82 | + current_service = "%s/%s@%s" % (service_class, service_hostname, service_realm) |
| 83 | + new_service = "%s/%s@%s" % (new_service_class, new_service_hostname, new_service_realm) |
| 84 | + logging.info('Changing service from %s to %s' % (current_service, new_service)) |
| 85 | + # the values are changed in the ticket |
| 86 | + decodedST['ticket']['sname']['name-string'][0] = new_service_class |
| 87 | + decodedST['ticket']['sname']['name-string'][1] = new_service_hostname |
| 88 | + decodedST['ticket']['realm'] = new_service_realm |
| 89 | + |
| 90 | + ticket = encoder.encode(decodedST) |
| 91 | + credential.ticket = CountedOctetString() |
| 92 | + credential.ticket['data'] = encoder.encode(decodedST['ticket'].clone(tagSet=Ticket.tagSet, cloneValueFlag=True)) |
| 93 | + credential.ticket['length'] = len(credential.ticket['data']) |
| 94 | + ccache.credentials[0] = credential |
| 95 | + |
| 96 | + # the values need to be changed in the ccache credentials |
| 97 | + # we already checked everything above, we can simply do the second replacement here |
| 98 | + ccache.credentials[0]['server'].fromPrincipal(Principal(new_service, type=constants.PrincipalNameType.NT_PRINCIPAL.value)) |
| 99 | + logging.info('Saving ticket in %s' % args.outticket) |
| 100 | + ccache.saveFile(args.outticket) |
| 101 | + |
| 102 | + |
| 103 | +def parse_args(): |
| 104 | + parser = argparse.ArgumentParser(add_help=True, description='Substitute an sname or SPN into an existing service ticket') |
| 105 | + |
| 106 | + parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON') |
| 107 | + parser.add_argument('-ts', action='store_true', help='Adds timestamp to every logging output') |
| 108 | + parser.add_argument('-in', dest='inticket', action="store", metavar="TICKET.CCACHE", help='input ticket to modify', required=True) |
| 109 | + parser.add_argument('-out', dest='outticket', action="store", metavar="TICKET.CCACHE", help='output ticket', required=True) |
| 110 | + parser.add_argument('-altservice', action="store", metavar="SERVICE", help='New sname/SPN', required=True) |
| 111 | + parser.add_argument('-force', action='store_true', help='Force the service substitution without taking the original into consideration') |
| 112 | + |
| 113 | + if len(sys.argv) == 1: |
| 114 | + parser.print_help() |
| 115 | + sys.exit(1) |
| 116 | + |
| 117 | + args = parser.parse_args() |
| 118 | + return args |
| 119 | + |
| 120 | + |
| 121 | +def init_logger(args): |
| 122 | + # Init the example's logger theme and debug level |
| 123 | + logger.init(args.ts) |
| 124 | + if args.debug is True: |
| 125 | + logging.getLogger().setLevel(logging.DEBUG) |
| 126 | + # Print the Library's installation path |
| 127 | + logging.debug(version.getInstallationPath()) |
| 128 | + else: |
| 129 | + logging.getLogger().setLevel(logging.INFO) |
| 130 | + logging.getLogger('impacket.smbserver').setLevel(logging.ERROR) |
| 131 | + |
| 132 | + |
| 133 | +def main(): |
| 134 | + print(version.BANNER) |
| 135 | + args = parse_args() |
| 136 | + init_logger(args) |
| 137 | + |
| 138 | + try: |
| 139 | + substitute_sname(args) |
| 140 | + except Exception as e: |
| 141 | + if logging.getLogger().level == logging.DEBUG: |
| 142 | + traceback.print_exc() |
| 143 | + logging.error(str(e)) |
| 144 | + |
| 145 | +if __name__ == '__main__': |
| 146 | + main() |
0 commit comments