Skip to content

Commit ba19543

Browse files
committed
refactor change-password
1 parent f7e1187 commit ba19543

1 file changed

Lines changed: 213 additions & 108 deletions

File tree

‎nxc/modules/change-password.py‎

Lines changed: 213 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,51 @@
1+
import contextlib
12
import sys
3+
24
from impacket.dcerpc.v5 import samr, epm, transport
3-
from impacket.dcerpc.v5.rpcrt import DCERPCException
5+
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
6+
from impacket.ldap.ldap import LDAPSessionError, MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE
7+
from nxc.parsers.ldap_results import parse_result_attributes
48
from nxc.helpers.misc import CATEGORY
59

610

711
class NXCModule:
812
"""
9-
Module for changing or resetting user passwords
10-
Module by Fagan Afandiyev, termanix and NeffIsBack
13+
Module for changing or resetting user passwords.
14+
Module by Fagan Afandiyev, termanix and NeffIsBack.
15+
Refactored by azoxlpf to support SMB (SAMR) and LDAP backends, including Kerberos auth.
1116
"""
1217

1318
name = "change-password"
14-
description = "Change or reset user passwords via various protocols"
15-
supported_protocols = ["smb"]
19+
description = "Change or reset user passwords via SAMR (SMB) or LDAP"
20+
supported_protocols = ["smb", "ldap"]
1621
category = CATEGORY.PRIVILEGE_ESCALATION
1722

23+
MUST_CHANGE_STATUSES = (
24+
"STATUS_PASSWORD_MUST_CHANGE",
25+
"STATUS_PASSWORD_EXPIRED",
26+
"STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT",
27+
)
28+
1829
def options(self, context, module_options):
1930
"""
2031
Required (one of):
2132
NEWPASS The new password of the user.
22-
NEWNTHASH The new NT hash of the user.
33+
NEWNTHASH The new NT hash of the user (SMB protocol only).
2334
2435
Optional:
2536
USER The user account if the target is not the current user.
2637
2738
Examples
2839
--------
29-
If STATUS_PASSWORD_MUST_CHANGE, STATUS_PASSWORD_EXPIRED or STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT (Change password for current user)
30-
netexec smb <DC_IP> -u username -p oldpass -M change-password -o NEWNTHASH='nthash'
31-
netexec smb <DC_IP> -u username -H oldnthash -M change-password -o NEWPASS='newpass'
40+
Self password/hash change (e.g. STATUS_PASSWORD_MUST_CHANGE / STATUS_PASSWORD_EXPIRED):
41+
nxc smb <DC_IP> -u username -p oldpass -M change-password -o NEWPASS='newpass'
42+
nxc smb <DC_IP> -u username -H oldnthash -M change-password -o NEWNTHASH='newnthash'
43+
nxc ldap <DC_IP> -u username -p oldpass -M change-password -o NEWPASS='newpass'
3244
33-
If want to change other user's password (with forcechangepassword priv or admin rights)
34-
netexec smb <DC_IP> -u username -p password -M change-password -o USER='target_user' NEWPASS='target_user_newpass'
35-
netexec smb <DC_IP> -u username -p password -M change-password -o USER='target_user' NEWNTHASH='target_user_newnthash'
45+
Reset another user's password (ForceChangePassword right or admin):
46+
nxc smb <DC_IP> -u admin -p password -M change-password -o USER='target' NEWPASS='newpass'
47+
nxc smb <DC_IP> -u admin -p password -M change-password -o USER='target' NEWNTHASH='newnthash'
48+
nxc ldap <DC_IP> -u admin -p password -M change-password -o USER='target' NEWPASS='newpass'
3649
"""
3750
self.newpass = module_options.get("NEWPASS")
3851
self.newhash = module_options.get("NEWNTHASH")
@@ -42,117 +55,209 @@ def options(self, context, module_options):
4255
context.log.fail("Either NEWPASS or NEWNTHASH is required!")
4356
sys.exit(1)
4457

45-
def authenticate(self, context, connection, protocol, anonymous=False):
46-
# Authenticate to the target using DCE/RPC with either user credentials or a null session. Establishes a connection and binds to the SAMR service.
47-
try:
48-
# Map to the SAMR endpoint on the target
49-
string_binding = epm.hept_map(connection.host, samr.MSRPC_UUID_SAMR, protocol=protocol)
50-
rpctransport = transport.DCERPCTransportFactory(string_binding)
51-
rpctransport.setRemoteHost(connection.host)
52-
53-
if anonymous:
54-
rpctransport.set_credentials("", "", "", "", "", "")
55-
rpctransport.set_kerberos(False, None)
56-
context.log.info("Connecting with null session credentials.")
57-
else:
58-
rpctransport.set_credentials(
59-
connection.username,
60-
connection.password,
61-
connection.domain,
62-
connection.lmhash,
63-
connection.nthash,
64-
aesKey=connection.aesKey,
65-
)
66-
context.log.info(f"Connecting as {connection.domain}\\{connection.username}")
67-
68-
# Connect to the DCE/RPC endpoint and bind to the SAMR service
69-
dce = rpctransport.get_dce_rpc()
70-
dce.connect()
71-
context.log.info("[+] Successfully connected to DCE/RPC")
72-
dce.bind(samr.MSRPC_UUID_SAMR)
73-
context.log.info("[+] Successfully bound to SAMR")
74-
return dce
75-
except DCERPCException as e:
76-
context.log.fail(f"DCE/RPC Exception: {e!s}")
77-
raise
78-
7958
def on_login(self, context, connection):
8059
self.context = context
81-
target_username = self.target_user or connection.username
82-
target_domain = connection.domain
83-
84-
# Grab all creds from the connection to use for authentication
85-
self.oldpass = connection.password
86-
self.oldhash = connection.nthash
60+
self.connection = connection
61+
self.target_username = self.target_user or connection.username
62+
self.target_domain = connection.domain
63+
self.is_self_change = self.target_username.lower() == connection.username.lower()
8764

88-
new_lmhash, new_nthash = "", ""
89-
90-
# Parse new hash values if provided
9165
if self.newhash:
9266
try:
93-
new_lmhash, new_nthash = self.newhash.split(":")
67+
_, self.new_nthash = self.newhash.split(":")
9468
except ValueError:
95-
new_nthash = self.newhash
69+
self.new_nthash = self.newhash
70+
else:
71+
self.new_nthash = ""
72+
73+
if connection.args.protocol == "smb":
74+
self.do_samr()
75+
elif connection.args.protocol == "ldap":
76+
self.do_ldap()
9677

78+
def db_remove_credential(self):
9779
try:
98-
self.dce = self.authenticate(context, connection, protocol="ncacn_np", anonymous=False)
80+
db = self.context.db
81+
domain = self.connection.domain
82+
rows = db.get_user(domain, self.computer_name) if hasattr(db, "get_user") else db.get_credentials(filter_term=self.computer_name)
83+
db.remove_credentials([row[0] for row in rows])
9984
except Exception as e:
100-
# Handle specific errors like password expiration or must be change
101-
if "STATUS_PASSWORD_MUST_CHANGE" in str(e) or "STATUS_PASSWORD_EXPIRED" in str(e) or "STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT" in str(e):
102-
context.log.warning("Password must be changed. Trying with null session.")
103-
self.dce = self.authenticate(context, connection, protocol="ncacn_ip_tcp", anonymous=True)
104-
elif "STATUS_LOGON_FAILURE" in str(e):
105-
context.log.fail("Authentication failure: wrong credentials.")
106-
return False
107-
else:
108-
raise
85+
self.context.log.debug(f"Could not remove credentials from DB: {e}")
10986

87+
def db_add_credential(self):
88+
if self.new_nthash:
89+
self.context.db.add_credential("hash", self.target_domain, self.target_username, self.new_nthash)
90+
else:
91+
self.context.db.add_credential("plaintext", self.target_domain, self.target_username, self.newpass)
92+
93+
def do_samr(self):
94+
dce = self.samr_connect()
95+
if dce is None:
96+
return
11097
try:
111-
# Perform the SMB SAMR password change
112-
self._smb_samr_change(context, connection, target_username, target_domain, self.oldhash, self.newpass, new_nthash)
98+
self.samr_execute(dce)
99+
finally:
100+
with contextlib.suppress(Exception):
101+
dce.disconnect()
102+
103+
def samr_connect(self):
104+
try:
105+
return self.samr_bind()
106+
except Exception as e:
107+
err = str(e)
113108

114-
# Remove user if exists to avoid outdated credentials when we update plaintext password, but hash exists (or vice versa)
115-
user = self.context.db.get_user(target_domain, target_username)
116-
user_ids = [row[0] for row in user]
117-
self.context.db.remove_credentials(user_ids)
109+
if "STATUS_LOGON_FAILURE" in err:
110+
self.context.log.fail("Authentication failure: wrong credentials.")
111+
return None
118112

119-
# Store the new credentials in the database
120-
if new_nthash:
121-
self.context.db.add_credential("hash", target_domain, target_username, new_nthash)
113+
must_change = not self.connection.kerberos and any(s in err for s in self.MUST_CHANGE_STATUSES)
114+
if not must_change:
115+
self.context.log.fail(f"Failed to connect to SAMR: {e}")
116+
return None
117+
118+
self.context.log.warning("Password must be changed. Falling back to null session over ncacn_ip_tcp.")
119+
try:
120+
return self.samr_bind(dce_protocol="ncacn_ip_tcp", anonymous=True)
121+
except Exception as e2:
122+
self.context.log.fail(f"Failed to bind to SAMR with null session: {e2}")
123+
return None
124+
125+
def samr_bind(self, dce_protocol="ncacn_np", anonymous=False):
126+
target = self.connection.host if not self.connection.kerberos else f"{self.connection.hostname}.{self.connection.domain}"
127+
string_binding = epm.hept_map(target, samr.MSRPC_UUID_SAMR, protocol=dce_protocol)
128+
rpc_transport = transport.DCERPCTransportFactory(string_binding)
129+
rpc_transport.setRemoteHost(target)
130+
131+
if anonymous:
132+
rpc_transport.set_credentials("", "", "", "", "", "")
133+
rpc_transport.set_kerberos(False, None)
134+
self.context.log.info("Connecting with null session credentials.")
135+
else:
136+
rpc_transport.set_credentials(
137+
self.connection.username,
138+
self.connection.password,
139+
self.connection.domain,
140+
self.connection.lmhash,
141+
self.connection.nthash,
142+
aesKey=self.connection.aesKey,
143+
)
144+
rpc_transport.set_kerberos(self.connection.kerberos, kdcHost=self.connection.kdcHost)
145+
self.context.log.info(f"Connecting as {self.connection.domain}\\{self.connection.username} (kerberos={self.connection.kerberos})")
146+
147+
dce = rpc_transport.get_dce_rpc()
148+
if not anonymous and self.connection.kerberos:
149+
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
150+
dce.connect()
151+
dce.bind(samr.MSRPC_UUID_SAMR)
152+
return dce
153+
154+
def samr_execute(self, dce):
155+
if self.is_self_change and not self.connection.password and not self.connection.nthash:
156+
self.context.log.fail("Self-change over SAMR requires the current password (-p) or NT hash (-H).")
157+
return
158+
159+
try:
160+
if not self.is_self_change:
161+
user_handle = self.samr_open_user(dce, self.target_username)
162+
samr.hSamrSetNTInternal1(dce, user_handle, self.newpass, self.new_nthash)
163+
self.context.log.success(f"Successfully reset password for '{self.target_username}'")
164+
elif self.newpass:
165+
samr.hSamrUnicodeChangePasswordUser2(dce, "\x00", self.target_username, self.connection.password, self.newpass, "", self.connection.nthash)
166+
self.context.log.success(f"Successfully changed password for '{self.target_username}'")
122167
else:
123-
self.context.db.add_credential("plaintext", target_domain, target_username, self.newpass)
168+
user_handle = self.samr_open_user(dce, self.target_username)
169+
samr.hSamrChangePasswordUser(dce, user_handle, self.connection.password, "", self.connection.nthash, "aad3b435b51404eeaad3b435b51404ee", self.new_nthash)
170+
self.context.log.success(f"Successfully changed password hash for '{self.target_username}'")
171+
self.context.log.highlight("Note: password must be changed at next logon.")
172+
173+
self.db_remove_credential()
174+
self.db_add_credential()
124175
except Exception as e:
125-
if "STATUS_ACCESS_DENIED" in str(e):
126-
self.context.log.fail(f"STATUS_ACCESS_DENIED while changing password for user: {target_username}")
127-
elif "STATUS_NONE_MAPPED" in str(e):
128-
self.context.log.fail(f"User '{target_username}' not found or not resolvable")
176+
err = str(e)
177+
if "STATUS_ACCESS_DENIED" in err:
178+
action = "change" if self.is_self_change else "reset"
179+
self.context.log.fail(f"{self.connection.username} does not have the right to {action} password for '{self.target_username}'")
180+
elif "STATUS_NONE_MAPPED" in err:
181+
self.context.log.fail(f"User '{self.target_username}' not found or not resolvable")
182+
elif "STATUS_PASSWORD_RESTRICTION" in err:
183+
self.context.log.fail(f"Password does not meet the domain policy for '{self.target_username}'")
184+
elif "STATUS_WRONG_PASSWORD" in err:
185+
self.context.log.fail("Wrong current password or NT hash provided for self-change")
129186
else:
130-
context.log.fail(f"SMB-SAMR password change failed: {e}")
131-
finally:
132-
self.dce.disconnect()
133-
134-
def _smb_samr_change(self, context, connection, target_username, target_domain, oldHash, newPassword, newHash):
135-
# Reset the password for a different user
136-
if target_username != connection.username:
137-
user_handle = self._hSamrOpenUser(connection, target_username)
138-
samr.hSamrSetNTInternal1(self.dce, user_handle, newPassword, newHash)
139-
context.log.success(f"Successfully changed password for {target_username}")
187+
self.context.log.fail(f"SMB-SAMR password change failed: {e}")
188+
189+
def samr_open_user(self, dce, username):
190+
server_handle = samr.hSamrConnect(dce, self.connection.host + "\x00")["ServerHandle"]
191+
domain_sid = samr.hSamrLookupDomainInSamServer(dce, server_handle, self.connection.domain)["DomainId"]
192+
domain_handle = samr.hSamrOpenDomain(dce, server_handle, domainId=domain_sid)["DomainHandle"]
193+
user_rid = samr.hSamrLookupNamesInDomain(dce, domain_handle, (username,))["RelativeIds"]["Element"][0]
194+
return samr.hSamrOpenUser(dce, domain_handle, userId=user_rid)["UserHandle"]
195+
196+
def do_ldap(self):
197+
if self.newhash:
198+
self.context.log.fail("Cannot set NEWNTHASH over LDAP. Use the smb protocol instead.")
199+
return
200+
if self.is_self_change and not self.connection.password:
201+
self.context.log.fail("Cannot perform self password change over LDAP without plaintext (-p). Use the smb protocol instead.")
202+
return
203+
204+
user_dn = self.ldap_find_user_dn(self.target_username)
205+
if user_dn is None:
206+
return
207+
208+
if self.is_self_change:
209+
self.ldap_self_change(user_dn)
140210
else:
141-
# Change password for the current user
142-
if newPassword:
143-
# Change the password with new password
144-
samr.hSamrUnicodeChangePasswordUser2(self.dce, "\x00", target_username, self.oldpass, newPassword, "", oldHash)
211+
self.ldap_admin_reset(user_dn)
212+
213+
def ldap_find_user_dn(self, username):
214+
try:
215+
resp = self.connection.search(
216+
searchFilter=f"(sAMAccountName={username})",
217+
attributes=["distinguishedName"],
218+
)
219+
parsed = parse_result_attributes(resp)
220+
if not parsed:
221+
self.context.log.fail(f"User '{username}' not found in LDAP")
222+
return None
223+
return parsed[0]["distinguishedName"]
224+
except Exception as e:
225+
self.context.log.fail(f"LDAP search failed for '{username}': {e}")
226+
return None
227+
228+
def ldap_self_change(self, dn):
229+
old_encoded = f'"{self.connection.password}"'.encode("utf-16-le")
230+
new_encoded = f'"{self.newpass}"'.encode("utf-16-le")
231+
try:
232+
self.connection.ldap_connection.modify(dn, {"unicodePwd": [(MODIFY_DELETE, [old_encoded]), (MODIFY_ADD, [new_encoded]),]},)
233+
self.context.log.success(f"Successfully changed password for '{self.target_username}'")
234+
self.db_remove_credential()
235+
self.db_add_credential()
236+
except LDAPSessionError as e:
237+
self.handle_ldap_error(e, action="change")
238+
239+
def ldap_admin_reset(self, dn):
240+
new_encoded = f'"{self.newpass}"'.encode("utf-16-le")
241+
try:
242+
self.connection.ldap_connection.modify(dn, {"unicodePwd": [(MODIFY_REPLACE, [new_encoded])]})
243+
self.context.log.success(f"Successfully reset password for '{self.target_username}'")
244+
self.db_remove_credential()
245+
self.db_add_credential()
246+
except LDAPSessionError as e:
247+
self.handle_ldap_error(e, action="reset")
248+
249+
def handle_ldap_error(self, exc, action):
250+
err = str(exc)
251+
if "noSuchObject" in err:
252+
self.context.log.fail(f"User '{self.target_username}' was not found")
253+
elif "insufficientAccessRights" in err:
254+
self.context.log.fail(f"Insufficient rights to {action} password for '{self.target_username}'")
255+
elif "unwillingToPerform" in err:
256+
if action == "change":
257+
self.context.log.fail(f"Server unwilling to perform: wrong current password or policy violation for '{self.target_username}'")
145258
else:
146-
# Change the password with new hash
147-
user_handle = self._hSamrOpenUser(connection, target_username)
148-
samr.hSamrChangePasswordUser(self.dce, user_handle, self.oldpass, "", oldHash, "aad3b435b51404eeaad3b435b51404ee", newHash)
149-
context.log.highlight("Note: Target user must change password at next logon.")
150-
context.log.success(f"Successfully changed password for {target_username}")
151-
152-
def _hSamrOpenUser(self, connection, username):
153-
"""Connect to the target server and retrieve the user handle"""
154-
server_handle = samr.hSamrConnect(self.dce, connection.host + "\x00")["ServerHandle"]
155-
domain_sid = samr.hSamrLookupDomainInSamServer(self.dce, server_handle, connection.domain)["DomainId"]
156-
domain_handle = samr.hSamrOpenDomain(self.dce, server_handle, domainId=domain_sid)["DomainHandle"]
157-
user_rid = samr.hSamrLookupNamesInDomain(self.dce, domain_handle, (username,))["RelativeIds"]["Element"][0]
158-
return samr.hSamrOpenUser(self.dce, domain_handle, userId=user_rid)["UserHandle"]
259+
self.context.log.fail(f"Server unwilling to perform password reset for '{self.target_username}'")
260+
elif "constraintViolation" in err:
261+
self.context.log.fail(f"Constraint violation for '{self.target_username}': new password does not meet the domain policy")
262+
else:
263+
self.context.log.fail(f"Failed to {action} password for '{self.target_username}': {exc}")

0 commit comments

Comments
 (0)