|
| 1 | +from impacket.ldap import ldaptypes |
| 2 | +from nxc.parsers.ldap_results import parse_result_attributes |
| 3 | +from ldap3.protocol.microsoft import security_descriptor_control |
| 4 | + |
| 5 | +RELEVANT_OBJECT_TYPES = { |
| 6 | + "00000000-0000-0000-0000-000000000000": "All Objects", |
| 7 | + "0feb936f-47b3-49f2-9386-1dedc2c23765": "msDS-DelegatedManagedServiceAccount", |
| 8 | +} |
| 9 | + |
| 10 | +EXCLUDED_SIDS_SUFFIXES = ["-512", "-519"] # Domain Admins, Enterprise Admins |
| 11 | +EXCLUDED_SIDS = ["S-1-5-32-544", "S-1-5-18"] # Builtin Administrators, Local SYSTEM |
| 12 | + |
| 13 | +# Define all access rights |
| 14 | +ACCESS_RIGHTS = { |
| 15 | + # Generic Rights |
| 16 | + "GenericRead": 0x80000000, # ADS_RIGHT_GENERIC_READ |
| 17 | + "GenericWrite": 0x40000000, # ADS_RIGHT_GENERIC_WRITE |
| 18 | + "GenericExecute": 0x20000000, # ADS_RIGHT_GENERIC_EXECUTE |
| 19 | + "GenericAll": 0x10000000, # ADS_RIGHT_GENERIC_ALL |
| 20 | + |
| 21 | + # Maximum Allowed access type |
| 22 | + "MaximumAllowed": 0x02000000, |
| 23 | + |
| 24 | + # Access System Acl access type |
| 25 | + "AccessSystemSecurity": 0x01000000, # ADS_RIGHT_ACCESS_SYSTEM_SECURITY |
| 26 | + |
| 27 | + # Standard access types |
| 28 | + "Synchronize": 0x00100000, # ADS_RIGHT_SYNCHRONIZE |
| 29 | + "WriteOwner": 0x00080000, # ADS_RIGHT_WRITE_OWNER |
| 30 | + "WriteDACL": 0x00040000, # ADS_RIGHT_WRITE_DAC |
| 31 | + "ReadControl": 0x00020000, # ADS_RIGHT_READ_CONTROL |
| 32 | + "Delete": 0x00010000, # ADS_RIGHT_DELETE |
| 33 | + |
| 34 | + # Specific rights |
| 35 | + "AllExtendedRights": 0x00000100, # ADS_RIGHT_DS_CONTROL_ACCESS |
| 36 | + "ListObject": 0x00000080, # ADS_RIGHT_DS_LIST_OBJECT |
| 37 | + "DeleteTree": 0x00000040, # ADS_RIGHT_DS_DELETE_TREE |
| 38 | + "WriteProperties": 0x00000020, # ADS_RIGHT_DS_WRITE_PROP |
| 39 | + "ReadProperties": 0x00000010, # ADS_RIGHT_DS_READ_PROP |
| 40 | + "Self": 0x00000008, # ADS_RIGHT_DS_SELF |
| 41 | + "ListChildObjects": 0x00000004, # ADS_RIGHT_ACTRL_DS_LIST |
| 42 | + "DeleteChild": 0x00000002, # ADS_RIGHT_DS_DELETE_CHILD |
| 43 | + "CreateChild": 0x00000001, # ADS_RIGHT_DS_CREATE_CHILD |
| 44 | +} |
| 45 | + |
| 46 | +# Define which rights are considered relevant for potential abuse |
| 47 | +RELEVANT_RIGHTS = { |
| 48 | + "GenericAll": ACCESS_RIGHTS["GenericAll"], |
| 49 | + "GenericWrite": ACCESS_RIGHTS["GenericWrite"], |
| 50 | + "WriteOwner": ACCESS_RIGHTS["WriteOwner"], |
| 51 | + "WriteDACL": ACCESS_RIGHTS["WriteDACL"], |
| 52 | + "CreateChild": ACCESS_RIGHTS["CreateChild"], |
| 53 | + "WriteProperties": ACCESS_RIGHTS["WriteProperties"], |
| 54 | + "AllExtendedRights": ACCESS_RIGHTS["AllExtendedRights"] |
| 55 | +} |
| 56 | + |
| 57 | +FUNCTIONAL_LEVELS = { |
| 58 | + "Windows 2000": 0, |
| 59 | + "Windows Server 2003": 1, |
| 60 | + "Windows Server 2003 R2": 2, |
| 61 | + "Windows Server 2008": 3, |
| 62 | + "Windows Server 2008 R2": 4, |
| 63 | + "Windows Server 2012": 5, |
| 64 | + "Windows Server 2012 R2": 6, |
| 65 | + "Windows Server 2016": 7, |
| 66 | + "Windows Server 2019": 8, |
| 67 | + "Windows Server 2022": 9, |
| 68 | + "Windows Server 2025": 10, |
| 69 | +} |
| 70 | + |
| 71 | + |
| 72 | +class NXCModule: |
| 73 | + """ |
| 74 | + ------- |
| 75 | + Module by @mpgn based on https://www.akamai.com/blog/security-research/abusing-dmsa-for-privilege-escalation-in-active-directory#credentials |
| 76 | + and https://raw.githubusercontent.com/akamai/BadSuccessor/refs/heads/main/Get-BadSuccessorOUPermissions.ps1 |
| 77 | + """ |
| 78 | + |
| 79 | + name = "badsuccessor" |
| 80 | + description = "Check if vulnerable to bad successor attack (DMSA)" |
| 81 | + supported_protocols = ["ldap"] |
| 82 | + opsec_safe = True |
| 83 | + multiple_hosts = True |
| 84 | + |
| 85 | + def __init__(self): |
| 86 | + self.context = None |
| 87 | + self.module_options = None |
| 88 | + |
| 89 | + def options(self, context, module_options): |
| 90 | + """No options available""" |
| 91 | + |
| 92 | + def is_excluded_sid(self, sid, domain_sid): |
| 93 | + if sid in EXCLUDED_SIDS: |
| 94 | + return True |
| 95 | + return any(sid.startswith(domain_sid) and sid.endswith(suffix) for suffix in EXCLUDED_SIDS_SUFFIXES) |
| 96 | + |
| 97 | + def get_domain_sid(self, ldap_session, base_dn): |
| 98 | + """Retrieve the domain SID from the domain object in LDAP""" |
| 99 | + r = ldap_session.search( |
| 100 | + searchBase=base_dn, |
| 101 | + searchFilter="(objectClass=domain)", |
| 102 | + attributes=["objectSid"] |
| 103 | + ) |
| 104 | + parsed = parse_result_attributes(r) |
| 105 | + if parsed and "objectSid" in parsed[0]: |
| 106 | + return parsed[0]["objectSid"] |
| 107 | + |
| 108 | + def find_bad_successor_ous(self, ldap_session, entries, base_dn): |
| 109 | + domain_sid = self.get_domain_sid(ldap_session, base_dn) |
| 110 | + results = {} |
| 111 | + parsed = parse_result_attributes(entries) |
| 112 | + for entry in parsed: |
| 113 | + dn = entry["distinguishedName"] |
| 114 | + sd_data = entry["nTSecurityDescriptor"] |
| 115 | + sd = ldaptypes.SR_SECURITY_DESCRIPTOR(data=sd_data) |
| 116 | + |
| 117 | + for ace in sd["Dacl"]["Data"]: |
| 118 | + if ace["AceType"] != ldaptypes.ACCESS_ALLOWED_ACE.ACE_TYPE: |
| 119 | + continue |
| 120 | + |
| 121 | + has_relevant_right = False |
| 122 | + mask = int(ace["Ace"]["Mask"]["Mask"]) |
| 123 | + for right_value in RELEVANT_RIGHTS.values(): |
| 124 | + if mask & right_value: |
| 125 | + has_relevant_right = True |
| 126 | + break |
| 127 | + |
| 128 | + if not has_relevant_right: |
| 129 | + continue # Skip this ACE if it doesn't have any relevant rights |
| 130 | + |
| 131 | + object_type = getattr(ace, "ObjectType", None) |
| 132 | + if object_type: |
| 133 | + object_guid = ldaptypes.bin_to_string(object_type).lower() |
| 134 | + if object_guid not in RELEVANT_OBJECT_TYPES: |
| 135 | + continue |
| 136 | + |
| 137 | + sid = ace["Ace"]["Sid"].formatCanonical() |
| 138 | + if self.is_excluded_sid(sid, domain_sid): |
| 139 | + continue |
| 140 | + |
| 141 | + results.setdefault(sid, []).append(dn) |
| 142 | + |
| 143 | + if hasattr(sd, "OwnerSid"): |
| 144 | + owner_sid = str(sd["OwnerSid"]) |
| 145 | + if not self.is_excluded_sid(owner_sid, domain_sid): |
| 146 | + results.setdefault(owner_sid, []).append(dn) |
| 147 | + return results |
| 148 | + |
| 149 | + def resolve_sid_to_name(self, ldap_session, sid, base_dn): |
| 150 | + """ |
| 151 | + Resolves a SID to a samAccountName using LDAP |
| 152 | +
|
| 153 | + Args: |
| 154 | + ---- |
| 155 | + ldap_session: The LDAP connection |
| 156 | + sid: The SID to resolve |
| 157 | + base_dn: The base DN for the LDAP search |
| 158 | +
|
| 159 | + Returns: |
| 160 | + ------- |
| 161 | + str: The samAccountName if found, otherwise the original SID |
| 162 | + """ |
| 163 | + try: |
| 164 | + search_filter = f"(objectSid={sid})" |
| 165 | + response = ldap_session.search( |
| 166 | + searchBase=base_dn, |
| 167 | + searchFilter=search_filter, |
| 168 | + attributes=["sAMAccountName"] |
| 169 | + ) |
| 170 | + |
| 171 | + parsed = parse_result_attributes(response) |
| 172 | + if parsed and "sAMAccountName" in parsed[0]: |
| 173 | + return parsed[0]["sAMAccountName"] |
| 174 | + return sid |
| 175 | + except Exception: |
| 176 | + return sid |
| 177 | + |
| 178 | + def on_login(self, context, connection): |
| 179 | + # Check functional domain level |
| 180 | + resp = connection.ldap_connection.search( |
| 181 | + searchBase=connection.ldap_connection._baseDN, |
| 182 | + searchFilter="(objectClass=domain)", |
| 183 | + attributes=["msDS-Behavior-Version"] |
| 184 | + ) |
| 185 | + parsed_resp = parse_result_attributes(resp) |
| 186 | + functional_domain_level = list(FUNCTIONAL_LEVELS.keys())[list(FUNCTIONAL_LEVELS.values()).index(int(parsed_resp[0]["msDS-Behavior-Version"]))] |
| 187 | + if int(parsed_resp[0]["msDS-Behavior-Version"]) < FUNCTIONAL_LEVELS["Windows Server 2025"]: |
| 188 | + context.log.fail(f"Attack won't work, domain functional level '{functional_domain_level}' is lower than Windows Server 2025, enumerating potential objects anyways.") |
| 189 | + else: |
| 190 | + context.log.success("Domain functional level is Windows Server 2025 or higher, attack is possible.") |
| 191 | + |
| 192 | + # Enumerate dMSA objects |
| 193 | + controls = security_descriptor_control(sdflags=0x07) # OWNER_SECURITY_INFORMATION |
| 194 | + resp = connection.ldap_connection.search( |
| 195 | + searchBase=connection.ldap_connection._baseDN, |
| 196 | + searchFilter="(objectClass=organizationalUnit)", |
| 197 | + attributes=["distinguishedName", "nTSecurityDescriptor"], |
| 198 | + searchControls=controls) # Fixed parameter name |
| 199 | + |
| 200 | + context.log.debug(f"Found {len(resp)} entries") |
| 201 | + |
| 202 | + results = self.find_bad_successor_ous(connection.ldap_connection, resp, connection.ldap_connection._baseDN) |
| 203 | + |
| 204 | + if results: |
| 205 | + context.log.success(f"Found {len(results)} results") |
| 206 | + else: |
| 207 | + context.log.highlight("No account found") |
| 208 | + |
| 209 | + for sid, ous in results.items(): |
| 210 | + samaccountname = self.resolve_sid_to_name( |
| 211 | + connection.ldap_connection, |
| 212 | + sid, |
| 213 | + connection.ldap_connection._baseDN |
| 214 | + ) |
| 215 | + |
| 216 | + for ou in ous: |
| 217 | + if sid == samaccountname: |
| 218 | + context.log.highlight(f"{sid}, {ou}") |
| 219 | + else: |
| 220 | + context.log.highlight(f"{samaccountname} ({sid}), {ou}") |
0 commit comments