Skip to content

Commit 1f07880

Browse files
authored
Merge branch 'Pennyw0rth:main' into refactor_SMBSpider
2 parents ae44a34 + 3591950 commit 1f07880

7 files changed

Lines changed: 267 additions & 6 deletions

File tree

nxc/cli.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ def gen_cli_args():
2525
COMMIT = ""
2626
DISTANCE = ""
2727
CODENAME = "SmoothOperator"
28-
nxc_logger.debug(f"NXC VERSION: {VERSION} - {CODENAME} - {COMMIT} - {DISTANCE}")
2928

3029
generic_parser = argparse.ArgumentParser(add_help=False, formatter_class=DisplayDefaultsNotNone)
3130
generic_group = generic_parser.add_argument_group("Generic", "Generic options for nxc across protocols")
@@ -133,7 +132,7 @@ def gen_cli_args():
133132
if hasattr(args, "get_output_tries"):
134133
args.get_output_tries = args.get_output_tries * 10
135134

136-
return args
135+
return args, [CODENAME, VERSION, COMMIT, DISTANCE]
137136

138137

139138
def get_module_names():

nxc/modules/change-password.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ def options(self, context, module_options):
3434
netexec smb <DC_IP> -u username -p password -M change-password -o USER='target_user' NEWPASS='target_user_newpass'
3535
netexec smb <DC_IP> -u username -p password -M change-password -o USER='target_user' NEWNTHASH='target_user_newnthash'
3636
"""
37-
self.context = context
3837
self.newpass = module_options.get("NEWPASS")
3938
self.newhash = module_options.get("NEWNTHASH")
4039
self.target_user = module_options.get("USER")
@@ -78,6 +77,7 @@ def authenticate(self, context, connection, protocol, anonymous=False):
7877
raise
7978

8079
def on_login(self, context, connection):
80+
self.context = context
8181
target_username = self.target_user or connection.username
8282
target_domain = connection.domain
8383

nxc/modules/presence.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import re
2+
from impacket.dcerpc.v5 import samr, tsch, transport
3+
from impacket.dcerpc.v5 import tsts as TSTS
4+
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
5+
from contextlib import suppress
6+
import traceback
7+
8+
9+
class NXCModule:
10+
"""
11+
Module to find Domain and Enterprise Admin presence on target systems over SMB.
12+
Made by @crosscutsaw, @NeffIsBack
13+
"""
14+
15+
name = "presence"
16+
description = "Traces Domain and Enterprise Admin presence in the target over SMB"
17+
supported_protocols = ["smb"]
18+
opsec_safe = True
19+
multiple_hosts = True
20+
21+
def options(self, context, module_options):
22+
"""There are no module options."""
23+
24+
def on_admin_login(self, context, connection):
25+
try:
26+
admin_users = self.enumerate_admin_users(context, connection)
27+
if not admin_users:
28+
context.log.fail("No admin users found.")
29+
return
30+
31+
# Update user objects to check if they are in tasklist, users directory or in scheduled tasks
32+
self.check_users_directory(context, connection, admin_users)
33+
self.check_tasklist(context, connection, admin_users)
34+
self.check_scheduled_tasks(context, connection, admin_users)
35+
36+
# print grouped/logged results nicely
37+
self.print_grouped_results(context, admin_users)
38+
except Exception as e:
39+
context.log.fail(str(e))
40+
context.log.debug(traceback.format_exc())
41+
42+
def get_dce_rpc(self, target, string_binding, dce_binding, connection):
43+
# Create a DCE/RPC transport object with the specified string binding
44+
rpctransport = transport.DCERPCTransportFactory(string_binding)
45+
rpctransport.setRemoteHost(target)
46+
rpctransport.set_credentials(
47+
connection.username,
48+
connection.password,
49+
connection.domain,
50+
connection.lmhash,
51+
connection.nthash,
52+
aesKey=connection.aesKey,
53+
)
54+
rpctransport.set_kerberos(connection.kerberos, connection.kdcHost)
55+
56+
# Connect to the DCE/RPC endpoint
57+
dce = rpctransport.get_dce_rpc()
58+
if connection.kerberos:
59+
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
60+
dce.set_credentials(*rpctransport.get_credentials())
61+
dce.connect()
62+
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
63+
dce.bind(dce_binding)
64+
return dce
65+
66+
def enumerate_admin_users(self, context, connection):
67+
admin_users = []
68+
69+
try:
70+
string_binding = fr"ncacn_np:{connection.kdcHost}[\pipe\samr]"
71+
dce = self.get_dce_rpc(connection.kdcHost, string_binding, samr.MSRPC_UUID_SAMR, connection)
72+
except Exception as e:
73+
context.log.fail(f"Failed to connect to SAMR: {e}")
74+
context.log.debug(traceback.format_exc())
75+
return admin_users
76+
77+
try:
78+
server_handle = samr.hSamrConnect2(dce)["ServerHandle"]
79+
domain = samr.hSamrEnumerateDomainsInSamServer(dce, server_handle)["Buffer"]["Buffer"][0]["Name"]
80+
resp = samr.hSamrLookupDomainInSamServer(dce, server_handle, domain)
81+
self.domain_sid = resp["DomainId"].formatCanonical()
82+
domain_handle = samr.hSamrOpenDomain(dce, server_handle, samr.DOMAIN_LOOKUP | samr.DOMAIN_LIST_ACCOUNTS, resp["DomainId"])["DomainHandle"]
83+
context.log.debug(f"Resolved domain SID for {domain}: {self.domain_sid}")
84+
except Exception as e:
85+
context.log.fail(f"Failed to open domain {domain}: {e!s}")
86+
context.log.debug(traceback.format_exc())
87+
return admin_users
88+
89+
admin_rids = {
90+
"Domain Admins": 512,
91+
"Enterprise Admins": 519,
92+
}
93+
94+
# Enumerate admin groups and their members
95+
for group_name, group_rid in admin_rids.items():
96+
context.log.debug(f"Looking up group: {group_name} with RID {group_rid}")
97+
98+
try:
99+
group_handle = samr.hSamrOpenGroup(dce, domain_handle, samr.GROUP_LIST_MEMBERS, group_rid)["GroupHandle"]
100+
resp = samr.hSamrGetMembersInGroup(dce, group_handle)
101+
for member in resp["Members"]["Members"]:
102+
rid = int.from_bytes(member.getData(), byteorder="little")
103+
try:
104+
user_handle = samr.hSamrOpenUser(dce, domain_handle, samr.MAXIMUM_ALLOWED, rid)["UserHandle"]
105+
username = samr.hSamrQueryInformationUser2(dce, user_handle, samr.USER_INFORMATION_CLASS.UserAllInformation)["Buffer"]["All"]["UserName"]
106+
107+
# If user already exists, append group name
108+
if any(u["sid"] == f"{self.domain_sid}-{rid}" for u in admin_users):
109+
user = next(u for u in admin_users if u["sid"] == f"{self.domain_sid}-{rid}")
110+
user["group"].append(group_name)
111+
else:
112+
admin_users.append({"username": username, "sid": f"{self.domain_sid}-{rid}", "domain": domain, "group": [group_name], "in_tasks": False, "in_directory": False, "in_scheduled_tasks": False})
113+
context.log.debug(f"Found user: {username} with RID {rid} in group {group_name}")
114+
except Exception as e:
115+
context.log.debug(f"Failed to get user info for RID {rid}: {e!s}")
116+
finally:
117+
with suppress(Exception):
118+
samr.hSamrCloseHandle(dce, user_handle)
119+
except Exception as e:
120+
context.log.debug(f"Failed to get members of group {group_name}: {e!s}")
121+
finally:
122+
with suppress(Exception):
123+
samr.hSamrCloseHandle(dce, group_handle)
124+
125+
return admin_users
126+
127+
def check_users_directory(self, context, connection, admin_users):
128+
dirs_found = set()
129+
130+
# try C$\Users first
131+
try:
132+
files = connection.conn.listPath("C$", "\\Users\\*")
133+
except Exception as e:
134+
context.log.debug(f"C$\\Users unavailable: {e}, trying Documents and Settings")
135+
try:
136+
files = connection.conn.listPath("C$", "\\Documents and Settings\\*")
137+
except Exception as e:
138+
context.log.fail(f"Error listing fallback directory: {e}")
139+
return
140+
else:
141+
context.log.debug("Successfully listed C$\\Users")
142+
143+
# collect folder names (lowercase) ignoring "." and ".."
144+
dirs_found.update([f.get_shortname().lower() for f in files if f.get_shortname().lower() not in [".", "..", "administrator"]])
145+
146+
# for admin users, check for folder presence
147+
for user in admin_users:
148+
# Look for administrator.domain to check if SID 500 Administrator is present (second check)
149+
if user["username"].lower() in dirs_found or \
150+
(user["username"].lower() == "administrator" and f"{user['username'].lower()}.{user['domain']}" in dirs_found):
151+
user["in_directory"] = True
152+
context.log.info(f"Found user {user['username']} in directories")
153+
154+
def check_tasklist(self, context, connection, admin_users):
155+
"""Checks tasklist over rpc."""
156+
try:
157+
with TSTS.LegacyAPI(connection.conn, connection.remoteName, kerberos=connection.kerberos) as legacy:
158+
handle = legacy.hRpcWinStationOpenServer()
159+
processes = legacy.hRpcWinStationGetAllProcesses(handle)
160+
except Exception as e:
161+
context.log.fail(f"Error in check_tasklist RPC method: {e}")
162+
return []
163+
164+
context.log.debug(f"Enumerated {len(processes)} processes on {connection.host}")
165+
166+
for process in processes:
167+
context.log.debug(f"ImageName: {process['ImageName']}, UniqueProcessId: {process['SessionId']}, pSid: {process['pSid']}")
168+
# Check if process SID matches any admin user SID
169+
for user in admin_users:
170+
if process["pSid"] == user["sid"]:
171+
user["in_tasks"] = True
172+
context.log.info(f"Matched process {process['ImageName']} with user {user['username']}")
173+
174+
def check_scheduled_tasks(self, context, connection, admin_users):
175+
"""Checks scheduled tasks over rpc."""
176+
try:
177+
target = connection.remoteName if connection.kerberos else connection.host
178+
stringbinding = f"ncacn_np:{target}[\\pipe\\atsvc]"
179+
dce = self.get_dce_rpc(target, stringbinding, tsch.MSRPC_UUID_TSCHS, connection)
180+
181+
# Also extract non admins where we can get the password
182+
self.non_admins = []
183+
non_admin_sids = set()
184+
185+
tasks = tsch.hSchRpcEnumTasks(dce, "\\")["pNames"]
186+
for task in tasks:
187+
xml = tsch.hSchRpcRetrieveTask(dce, task["Data"])["pXml"]
188+
# Extract SID and LogonType from the XML, if LogonType is "Password" we should be able to extract the password
189+
sid = re.search(fr"<UserId>({self.domain_sid}-\d{{3,}})</UserId>", xml)
190+
logon_type = re.search(r"<LogonType>(\w+)</LogonType>", xml)
191+
192+
# Check if SID and LogonType are found, then check if SID matches any admin user
193+
if sid and logon_type and logon_type.group(1) == "Password":
194+
context.log.debug(f"Found scheduled task '{task['Data']}' with SID {sid} and LogonType {logon_type.group(1)}")
195+
if any(user["sid"] == sid.group(1) for user in admin_users):
196+
user = next(user for user in admin_users if user["sid"] == sid.group(1))
197+
user["in_scheduled_tasks"] = True
198+
else:
199+
# If not an admin user, add to non_admin_sids for further processing
200+
non_admin_sids.add(sid.group(1))
201+
202+
if non_admin_sids:
203+
string_binding = fr"ncacn_np:{connection.kdcHost}[\pipe\samr]"
204+
dce = self.get_dce_rpc(connection.kdcHost, string_binding, samr.MSRPC_UUID_SAMR, connection)
205+
206+
# Get Domain Handle
207+
server_handle = samr.hSamrConnect2(dce)["ServerHandle"]
208+
domain = samr.hSamrEnumerateDomainsInSamServer(dce, server_handle)["Buffer"]["Buffer"][0]["Name"]
209+
domain_sid = samr.hSamrLookupDomainInSamServer(dce, server_handle, domain)["DomainId"]
210+
domain_handle = samr.hSamrOpenDomain(dce, server_handle, samr.DOMAIN_LOOKUP | samr.DOMAIN_LIST_ACCOUNTS, domain_sid)["DomainHandle"]
211+
212+
for sid in non_admin_sids:
213+
user_handle = samr.hSamrOpenUser(dce, domain_handle, samr.MAXIMUM_ALLOWED, int(sid.split("-")[-1]))["UserHandle"]
214+
username = samr.hSamrQueryInformationUser2(dce, user_handle, samr.USER_INFORMATION_CLASS.UserAllInformation)["Buffer"]["All"]["UserName"]
215+
self.non_admins.append(username)
216+
217+
except Exception as e:
218+
context.log.fail(f"Failed to enumerate scheduled tasks: {e}")
219+
context.log.debug(traceback.format_exc())
220+
221+
def print_grouped_results(self, context, admin_users):
222+
"""Logs all results grouped per host in order"""
223+
# Make less verbose for scanning large ranges
224+
context.log.info(f"Identified Admin Users: {', '.join([user['username'] for user in admin_users])}")
225+
226+
# Print directory users
227+
dir_users = [user for user in admin_users if user["in_directory"]]
228+
if dir_users:
229+
context.log.success("Found admins in directories:")
230+
for user in dir_users:
231+
context.log.highlight(f"{user['username']} ({', '.join(user['group'])})")
232+
233+
# Print tasklist users
234+
tasklist_users = [user for user in admin_users if user["in_tasks"]]
235+
if tasklist_users:
236+
context.log.success("Found admins in tasklist:")
237+
for user in tasklist_users:
238+
context.log.highlight(f"{user['username']} ({', '.join(user['group'])})")
239+
240+
# Print scheduled tasks users
241+
scheduled_tasks_users = [user for user in admin_users if user["in_scheduled_tasks"]]
242+
if scheduled_tasks_users:
243+
context.log.success("Found admins in scheduled tasks:")
244+
for user in scheduled_tasks_users:
245+
context.log.highlight(f"{user['username']} ({', '.join(user['group'])})")
246+
if self.non_admins:
247+
context.log.info(f"Found {len(self.non_admins)} non-admin scheduled tasks:")
248+
for sid in self.non_admins:
249+
context.log.info(sid)
250+
251+
# Making this less verbose to better scan large ranges
252+
if not dir_users and not tasklist_users:
253+
context.log.info("No matches found in users directory, tasklist or scheduled tasks.")

nxc/netexec.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ async def start_run(protocol_obj, args, db, targets): # noqa: RUF029
6969

7070
def main():
7171
first_run_setup(nxc_logger)
72-
args = gen_cli_args()
72+
args, version_info = gen_cli_args()
7373

7474
# if these are the same, it might double log to file (two FileHandlers will be added)
7575
# but this should never happen by accident
@@ -78,6 +78,8 @@ def main():
7878
if hasattr(args, "log") and args.log:
7979
nxc_logger.add_file_log(args.log)
8080

81+
CODENAME, VERSION, COMMIT, DISTANCE = version_info
82+
nxc_logger.debug(f"NXC VERSION: {VERSION} - {CODENAME} - {COMMIT} - {DISTANCE}")
8183
nxc_logger.debug(f"PYTHON VERSION: {sys.version}")
8284
nxc_logger.debug(f"RUNNING ON: {platform.system()} Release: {platform.release()}")
8385
nxc_logger.debug(f"Passed args: {args}")

nxc/protocols/ldap.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,8 @@ def query(self):
10701070
self.logger.fail(f"LDAP Filter Syntax Error: {e}")
10711071
return
10721072
for idx, entry in enumerate(resp_parsed):
1073+
if not isinstance(resp[idx], ldapasn1_impacket.SearchResultEntry):
1074+
idx += 1 # Skip non-entry responses
10731075
self.logger.success(f"Response for object: {resp[idx]['objectName']}")
10741076
for attribute in entry:
10751077
if isinstance(entry[attribute], list) and entry[attribute]:

nxc/protocols/nfs.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,11 @@ def ls(self):
622622
# NORMAL LS CALL (without root escape)
623623
if self.args.share:
624624
mount_info = self.mount.mnt(self.args.share, self.auth)
625-
mount_fh = mount_info["mountinfo"]["fhandle"]
625+
if mount_info["status"] != 0:
626+
self.logger.fail(f"Could not mount share {self.args.share}: {NFSSTAT3[mount_info['status']]}")
627+
return
628+
else:
629+
mount_fh = mount_info["mountinfo"]["fhandle"]
626630
elif self.root_escape:
627631
# Interestingly we don't actually have to mount the share if we already got the handle
628632
self.logger.success(f"Successful escape on share: {self.escape_share}")

nxc/protocols/smb.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ def get_os_arch(self):
169169

170170
def enum_host_info(self):
171171
self.local_ip = self.conn.getSMBServer().get_socket().getsockname()[0]
172+
self.is_host_dc()
172173

173174
try:
174175
self.conn.login("", "")
@@ -190,7 +191,7 @@ def enum_host_info(self):
190191
else:
191192
try:
192193
# If we know the host is a DC we can still get the hostname over LDAP if NTLM is not available
193-
if self.is_host_dc() and detect_if_ip(self.host):
194+
if self.isdc and detect_if_ip(self.host):
194195
self.hostname, self.domain = LDAPResolution(self.host).get_resolution()
195196
self.targetDomain = self.domain
196197
# If we can't authenticate with NTLM and the target is supplied as a FQDN we must parse it

0 commit comments

Comments
 (0)