Skip to content

Commit 541a2ae

Browse files
authored
Merge branch 'Pennyw0rth:main' into main
2 parents aa9981f + 0befdd0 commit 541a2ae

4 files changed

Lines changed: 239 additions & 5 deletions

File tree

nxc/connection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def call_modules(self):
276276
extra={
277277
"module_name": module.name.upper(),
278278
"host": self.host,
279-
"port": self.args.port,
279+
"port": self.port,
280280
"hostname": self.hostname,
281281
},
282282
)

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}")

nxc/modules/ntdsutil.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ def add_ntds_hash(ntds_hash, host_id):
142142
add_ntds_hash.ntds_hashes = 0
143143
add_ntds_hash.added_to_db = 0
144144

145+
connection.output_filename = connection.output_file_template.format(output_folder="ntds")
146+
145147
NTDS = NTDSHashes(
146148
f"{self.dir_result}/Active Directory/ntds.dit",
147149
boot_key,

nxc/protocols/ldap.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def __init__(self, args, db, host):
151151
self.admin_privs = False
152152
self.no_ntlm = False
153153
self.sid_domain = ""
154+
self.scope = None
154155

155156
connection.__init__(self, args, db, host)
156157

@@ -249,6 +250,8 @@ def enum_host_info(self):
249250
if ntlm_challenge:
250251
ntlm_info = parse_challenge(ntlm_challenge)
251252
self.server_os = ntlm_info["os_version"]
253+
else:
254+
self.no_ntlm = True
252255

253256
if self.args.domain:
254257
self.domain = self.args.domain
@@ -372,6 +375,7 @@ def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="",
372375
# Connect to LDAPS
373376
self.logger.extra["protocol"] = "LDAPS"
374377
self.logger.extra["port"] = "636"
378+
self.port = 636
375379
ldaps_url = f"ldaps://{self.target}"
376380
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [2]")
377381
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, signing=False)
@@ -419,6 +423,7 @@ def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="",
419423
return False
420424

421425
def plaintext_login(self, domain, username, password):
426+
422427
self.username = username
423428
self.password = password
424429
self.domain = domain
@@ -459,6 +464,7 @@ def plaintext_login(self, domain, username, password):
459464
# Connect to LDAPS
460465
self.logger.extra["protocol"] = "LDAPS"
461466
self.logger.extra["port"] = "636"
467+
self.port = 636
462468
ldaps_url = f"ldaps://{self.target}"
463469
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [4]")
464470
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, signing=False)
@@ -549,6 +555,7 @@ def hash_login(self, domain, username, ntlm_hash):
549555
# We need to try SSL
550556
self.logger.extra["protocol"] = "LDAPS"
551557
self.logger.extra["port"] = "636"
558+
self.port = 636
552559
ldaps_url = f"ldaps://{self.target}"
553560
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
554561
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, signing=False)
@@ -623,7 +630,7 @@ def getUnixTime(self, t):
623630
return t
624631

625632
def search(self, searchFilter, attributes, sizeLimit=0, baseDN=None) -> list:
626-
if baseDN is None and self.args.base_dn:
633+
if baseDN is None and self.args.base_dn is not None:
627634
baseDN = self.args.base_dn
628635
elif baseDN is None:
629636
baseDN = self.baseDN
@@ -633,19 +640,24 @@ def search(self, searchFilter, attributes, sizeLimit=0, baseDN=None) -> list:
633640
self.logger.debug(f"Search Filter={searchFilter}")
634641

635642
# Microsoft Active Directory set an hard limit of 1000 entries returned by any search
636-
paged_search_control = ldapasn1_impacket.SimplePagedResultsControl(criticality=True, size=1000)
643+
paged_search_control = [ldapasn1_impacket.SimplePagedResultsControl(criticality=True, size=1000)] if not self.no_ntlm else ""
637644
return self.ldap_connection.search(
645+
scope=self.scope,
638646
searchBase=baseDN,
639647
searchFilter=searchFilter,
640648
attributes=attributes,
641649
sizeLimit=sizeLimit,
642-
searchControls=[paged_search_control],
650+
searchControls=paged_search_control,
643651
)
644652
except ldap_impacket.LDAPSearchError as e:
645-
if e.getErrorString().find("sizeLimitExceeded") >= 0:
653+
if "sizeLimitExceeded" in str(e):
646654
# We should never reach this code as we use paged search now
647655
self.logger.fail("sizeLimitExceeded exception caught, giving up and processing the data received")
648656
e.getAnswers()
657+
# if empty username and password is possible that we need to change the scope, we try with a baseObject before returning a fail
658+
elif "operationsError" in str(e) and self.scope is None and self.username == "" and self.password == "":
659+
self.scope = ldapasn1_impacket.Scope("baseObject")
660+
return self.search(searchFilter, attributes, sizeLimit, baseDN)
649661
else:
650662
self.logger.fail(e)
651663
return []

0 commit comments

Comments
 (0)