Skip to content

Commit e982a68

Browse files
authored
Merge pull request Pennyw0rth#386 from Pennyw0rth/neff-sccm
Adding SCCM LDAP Reconnaissance to NetExec
2 parents f8ac2e1 + 413e5cd commit e982a68

3 files changed

Lines changed: 291 additions & 1 deletion

File tree

nxc/modules/sccm.py

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
from impacket.ldap import ldap, ldaptypes, ldapasn1 as ldapasn1_impacket
2+
from impacket.ldap.ldap import LDAPSearchError
3+
from ldap3.protocol.microsoft import security_descriptor_control
4+
from nxc.parsers.ldap_results import parse_result_attributes
5+
6+
SAM_USER_OBJECT = 0x30000000
7+
SAM_MACHINE_ACCOUNT = 0x30000001
8+
SAM_GROUP_OBJECT = 0x10000000
9+
LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
10+
11+
12+
class NXCModule:
13+
"""
14+
Implementation of the SCCM RECON-1 technique to find SCCM related objects in Active Directory.
15+
See:
16+
https://github.com/subat0mik/Misconfiguration-Manager/blob/main/attack-techniques/RECON/RECON-1/recon-1_description.md
17+
https://github.com/garrettfoster13/sccmhunter
18+
19+
Module by @NeffIsBack
20+
"""
21+
22+
name = "sccm"
23+
description = "Find a SCCM infrastructure in the Active Directory"
24+
supported_protocols = ["ldap"]
25+
opsec_safe = True
26+
multiple_hosts = True
27+
28+
def __init__(self):
29+
self.sccm_site_servers = [] # List of dns host names of the SCCM site servers
30+
self.sccm_sites = {} # List of SCCM sites with their management points (Sorted by site code)
31+
self.base_dn = ""
32+
self.recursive_resolve = False
33+
34+
self.user_objects = []
35+
self.computer_objects = []
36+
self.group_objects = {}
37+
38+
def options(self, context, module_options):
39+
"""
40+
BASE_DN The base domain name for the LDAP query
41+
REC_RESOLVE Resolve members of groups recursively. Default: False
42+
"""
43+
if module_options and "BASE_DN" in module_options:
44+
self.base_dn = module_options["BASE_DN"]
45+
if module_options and "REC_RESOLVE" in module_options:
46+
self.recursive_resolve = bool(module_options["REC_RESOLVE"])
47+
48+
def on_login(self, context, connection):
49+
"""On a successful LDAP login we perform a search for all PKI Enrollment Server or Certificate Templates Names."""
50+
self.context = context
51+
self.connection = connection
52+
self.base_dn = connection.ldapConnection._baseDN if not self.base_dn else self.base_dn
53+
self.sc = ldap.SimplePagedResultsControl()
54+
55+
# Basic SCCM enumeration
56+
try:
57+
# Search for SCCM root object
58+
search_filter = f"(distinguishedName=CN=System Management,CN=System,{self.base_dn})"
59+
controls = security_descriptor_control(sdflags=0x04)
60+
context.log.display(f"Looking for the SCCM container with filter: '{search_filter}'")
61+
result = connection.ldapConnection.search(
62+
searchFilter=search_filter,
63+
attributes=["nTSecurityDescriptor"],
64+
sizeLimit=0,
65+
searchControls=controls,
66+
searchBase=self.base_dn,
67+
)
68+
69+
# There should be only one result
70+
for item in result:
71+
if isinstance(item, ldapasn1_impacket.SearchResultEntry):
72+
self.context.log.success(f"Found SCCM object: {item[0]}")
73+
self.get_site_servers(item)
74+
self.get_sites()
75+
self.get_management_points()
76+
77+
# Print results
78+
self.context.log.success(f"Found {len(self.sccm_site_servers)} Site Servers:")
79+
for site in self.sccm_site_servers:
80+
ip = self.connection.resolver(site)
81+
self.context.log.highlight(f"{site} - {ip['host'] if ip else 'unknown'}")
82+
self.context.log.success(f"Found {len(self.sccm_sites)} SCCM Sites:")
83+
for site in self.sccm_sites:
84+
self.context.log.highlight(f"{self.sccm_sites[site]['cn']}")
85+
self.context.log.highlight(f" Site Code: {site.rjust(14)}")
86+
self.context.log.highlight(f" Assignment Site Code: {self.sccm_sites[site]['AssignmentSiteCode'].rjust(3)}")
87+
88+
# If there aren't Management Points, it's a Central Administration Site
89+
if self.sccm_sites[site]["ManagementPoints"]:
90+
self.context.log.highlight(f" CAS: {' ':<17}{False}")
91+
self.context.log.highlight(" Management Points:")
92+
for mp in self.sccm_sites[site]["ManagementPoints"]:
93+
self.context.log.highlight(f" CN:{' ':<12}{mp['cn']}")
94+
self.context.log.highlight(f" DNS Hostname:{' ':<2}{mp['dNSHostName']}")
95+
self.context.log.highlight(f" IP Address:{' ':<4}{mp['IPAddress']}")
96+
self.context.log.highlight(f" Default MP:{' ':<4}{mp['mSSMSDefaultMP']}")
97+
else:
98+
self.context.log.highlight(f" CAS: {' ':<17}{True}")
99+
self.context.log.highlight("")
100+
except LDAPSearchError as e:
101+
context.log.fail(f"Got unexpected exception: {e}")
102+
103+
# SCCM named objects enumeration
104+
self.get_sccm_named_objects(context, connection)
105+
if self.user_objects:
106+
context.log.success(f"Found {len(self.user_objects)} SCCM related user objects:")
107+
for user in self.user_objects:
108+
context.log.highlight(user)
109+
if self.computer_objects:
110+
context.log.success(f"Found {len(self.computer_objects)} SCCM related computer objects:")
111+
for computer in self.computer_objects:
112+
context.log.highlight(computer)
113+
if self.group_objects:
114+
context.log.success(f"Found {len(self.group_objects)} SCCM related group objects:")
115+
for group in self.group_objects:
116+
context.log.highlight(self.group_objects[group]["sAMAccountName"])
117+
for child in self.group_objects[group]["children"]:
118+
if int(child["sAMAccountType"]) == SAM_USER_OBJECT:
119+
context.log.highlight(f" {child['sAMAccountName']} -> User")
120+
elif int(child["sAMAccountType"]) == SAM_MACHINE_ACCOUNT:
121+
context.log.highlight(f" {child['sAMAccountName']} -> Computer")
122+
elif int(child["sAMAccountType"]) == SAM_GROUP_OBJECT:
123+
context.log.highlight(f" {child['sAMAccountName']} -> Group")
124+
125+
def get_sccm_named_objects(self, context, connection):
126+
"""Enumerate users/groups/computers with "SCCM" in their name"""
127+
# hippity hoppity your code is now my property, filter stolen from the awesome sccmhunter repository
128+
# https://github.com/garrettfoster13/sccmhunter
129+
try:
130+
yoinkers = "(|(samaccountname=*sccm*)(samaccountname=*mecm*)(description=*sccm*)(description=*mecm*)(name=*sccm*)(name=*mecm*))"
131+
context.log.display("Searching for SCCM related objects")
132+
result = connection.ldapConnection.search(
133+
searchFilter=yoinkers,
134+
searchBase=self.base_dn,
135+
attributes=["sAMAccountName", "distinguishedName", "sAMAccountType"],
136+
)
137+
138+
result = parse_result_attributes(result)
139+
for res in result:
140+
if "sAMAccountType" in res and int(res["sAMAccountType"]) == SAM_USER_OBJECT:
141+
self.user_objects.append(res["sAMAccountName"])
142+
elif "sAMAccountType" in res and int(res["sAMAccountType"]) == SAM_MACHINE_ACCOUNT:
143+
self.computer_objects.append(res["sAMAccountName"])
144+
elif "sAMAccountType" in res and int(res["sAMAccountType"]) == SAM_GROUP_OBJECT:
145+
self.group_objects[res["distinguishedName"]] = {
146+
"sAMAccountName": res["sAMAccountName"],
147+
"children": [],
148+
}
149+
if self.recursive_resolve:
150+
self.resolve_recursive(res["distinguishedName"])
151+
152+
except LDAPSearchError as e:
153+
context.log.fail(f"Got unexpected exception: {e}")
154+
155+
def resolve_recursive(self, dn):
156+
"""Recursively resolve members of a group."""
157+
try:
158+
self.context.log.debug(f"Resolving group members recursively for {dn}")
159+
# Somehow BaseDN is not working together with the LDAP_MATCHING_RULE_IN_CHAIN
160+
result = self.connection.ldapConnection.search(
161+
searchFilter=f"(memberOf:{LDAP_MATCHING_RULE_IN_CHAIN}:={dn})",
162+
attributes=["sAMAccountName", "distinguishedName", "sAMAccountType"],
163+
)
164+
165+
result = parse_result_attributes(result)
166+
for res in result:
167+
self.group_objects[dn]["children"].append({
168+
"sAMAccountName": res["sAMAccountName"],
169+
"distinguishedName": res["distinguishedName"],
170+
"sAMAccountType": res["sAMAccountType"],
171+
})
172+
173+
except LDAPSearchError as e:
174+
self.context.log.error(f"Error resolving group members: {e}")
175+
176+
def get_management_points(self):
177+
"""Searches for all SCCM management points in the Active Directory and maps them to their SCCM site via the site code."""
178+
try:
179+
response = self.connection.ldapConnection.search(
180+
searchBase=self.base_dn,
181+
searchFilter="(objectClass=mSSMSManagementPoint)",
182+
attributes=["cn", "dNSHostName", "mSSMSDefaultMP", "mSSMSSiteCode"],
183+
)
184+
185+
response_parsed = parse_result_attributes(response)
186+
187+
for mp in response_parsed:
188+
ip = self.connection.resolver(mp["dNSHostName"])
189+
self.sccm_sites[mp["mSSMSSiteCode"]]["ManagementPoints"].append({
190+
"cn": mp["cn"],
191+
"dNSHostName": mp["dNSHostName"],
192+
"IPAddress": ip["host"] if ip else "-",
193+
"mSSMSDefaultMP": mp["mSSMSDefaultMP"],
194+
})
195+
196+
except LDAPSearchError as e:
197+
self.context.log.error(f"Error searching for management points: {e}")
198+
199+
def get_sites(self):
200+
"""Searches for all SCCM sites in the Active Directory, sorted by site code."""
201+
try:
202+
response = self.connection.ldapConnection.search(
203+
searchBase=self.base_dn,
204+
searchFilter="(objectClass=mSSMSSite)",
205+
attributes=["cn", "mSSMSSiteCode", "mSSMSAssignmentSiteCode"],
206+
)
207+
208+
response_parsed = parse_result_attributes(response)
209+
210+
for site in response_parsed:
211+
self.sccm_sites[site["mSSMSSiteCode"]] = {
212+
"cn": site["cn"],
213+
"AssignmentSiteCode": site["mSSMSAssignmentSiteCode"],
214+
"ManagementPoints": []
215+
}
216+
217+
except LDAPSearchError as e:
218+
self.context.log.error(f"Error searching for sites: {e}")
219+
220+
def get_site_servers(self, item):
221+
"""Extracts the site servers from the root SCCM object."""
222+
raw_sec_descriptor = str(item[1][0][1][0]).encode("latin-1")
223+
principal_security_descriptor = ldaptypes.SR_SECURITY_DESCRIPTOR(data=raw_sec_descriptor)
224+
self.parse_dacl(principal_security_descriptor["Dacl"])
225+
self.sccm_site_servers = set(self.sccm_site_servers) # Make list unique
226+
227+
def parse_dacl(self, dacl):
228+
"""Parses a DACL and extracts the dns host names with full control over the SCCM object."""
229+
self.context.log.debug("Parsing DACL")
230+
for ace in dacl["Data"]:
231+
self.parse_ace(ace)
232+
233+
def parse_ace(self, ace):
234+
"""Parses an ACE and resolves the SID if the SID of the ACE has full control."""
235+
if ace["TypeName"] in ["ACCESS_ALLOWED_ACE", "ACCESS_ALLOWED_OBJECT_ACE"]:
236+
ace = ace["Ace"]
237+
sid = ace["Sid"].formatCanonical()
238+
mask = ace["Mask"]
239+
fullcontrol = 0xf01ff
240+
if mask.hasPriv(fullcontrol):
241+
self.resolve_SID(sid)
242+
243+
def resolve_SID(self, sid):
244+
"""Tries to resolve a SID and add the dNSHostName to the sccm site list."""
245+
try:
246+
self.context.log.debug(f"Resolving SID: {sid}")
247+
result = self.connection.ldapConnection.search(
248+
searchBase=self.base_dn,
249+
searchFilter=f"(objectSid={sid})",
250+
attributes=["sAMAccountName", "sAMAccountType", "member", "dNSHostName"],
251+
)
252+
253+
parsed_result = parse_result_attributes(result)
254+
255+
if not parsed_result:
256+
return None
257+
else:
258+
parsed_result = parsed_result[0] # We only have one result as we always query a single SID
259+
260+
if int(parsed_result["sAMAccountType"]) == SAM_MACHINE_ACCOUNT:
261+
self.context.log.debug(f"Found object with full control over SCCM object. SID: {sid}, dns_hostname: {parsed_result['dNSHostName']}")
262+
self.sccm_site_servers.append(parsed_result["dNSHostName"])
263+
elif int(parsed_result["sAMAccountType"]) == SAM_GROUP_OBJECT:
264+
if isinstance(parsed_result["member"], list):
265+
for member in parsed_result["member"]:
266+
member_sid = self.dn_to_sid(member)
267+
if member_sid:
268+
self.resolve_SID(member_sid)
269+
else: # Group has only one member
270+
member_sid = self.dn_to_sid(parsed_result["member"])
271+
if member_sid:
272+
self.resolve_SID(member_sid)
273+
274+
except Exception as e:
275+
self.context.log.debug(f"SID not found in LDAP: {sid}, {e}")
276+
return ""
277+
278+
def dn_to_sid(self, dn) -> str:
279+
"""Tries to resolve a DN to a SID."""
280+
result = self.connection.ldapConnection.search(
281+
searchBase=self.base_dn,
282+
searchFilter=f"(distinguishedName={dn})",
283+
attributes=["sAMAccountName", "objectSid"],
284+
)
285+
286+
# Extract the SID of the object
287+
sid_raw = bytes(result[0][1][0][1].components[0])
288+
return ldaptypes.LDAP_SID(data=sid_raw).formatCanonical()

nxc/parsers/ldap_results.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ def parse_result_attributes(ldap_response):
88
continue
99
attribute_map = {}
1010
for attribute in entry["attributes"]:
11-
attribute_map[str(attribute["type"])] = str(attribute["vals"][0])
11+
val = [str(val) for val in attribute["vals"].components]
12+
attribute_map[str(attribute["type"])] = val if len(val) > 1 else val[0]
1213
parsed_response.append(attribute_map)
1314
return parsed_response

nxc/protocols/smb.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,7 @@ def shares(self):
858858
self.logger.highlight(f"{name:<15} {','.join(perms):<15} {remark}")
859859
return permissions
860860

861+
@requires_admin
861862
def interfaces(self):
862863
"""
863864
Retrieve the list of network interfaces info (Name, IP Address, Subnet Mask, Default Gateway) from remote Windows registry'

0 commit comments

Comments
 (0)