Skip to content

Commit c15faf1

Browse files
authored
Merge pull request Pennyw0rth#702 from Pennyw0rth/badsuccesor
Add Badsuccessor module
2 parents e6fe634 + b84d04a commit c15faf1

1 file changed

Lines changed: 220 additions & 0 deletions

File tree

nxc/modules/badsuccessor.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

Comments
 (0)