1+ import contextlib
12import sys
3+
24from 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
48from nxc .helpers .misc import CATEGORY
59
610
711class 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