From 469671e59d1c124fa428a9cd2097ca393bba9af3 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Thu, 21 Nov 2024 17:11:17 +1100 Subject: [PATCH 1/5] Added LDAP password change module --- lib/msf/core/exploit/remote/ldap.rb | 2 + .../auxiliary/admin/ldap/change_password.rb | 159 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100755 modules/auxiliary/admin/ldap/change_password.rb diff --git a/lib/msf/core/exploit/remote/ldap.rb b/lib/msf/core/exploit/remote/ldap.rb index 216ea212f9b5..cdae87130be6 100644 --- a/lib/msf/core/exploit/remote/ldap.rb +++ b/lib/msf/core/exploit/remote/ldap.rb @@ -285,6 +285,8 @@ def validate_query_result!(query_result, filter=nil) fail_with(Msf::Module::Failure::NotFound, 'The LDAP operation failed because the referenced attribute does not exist.') when 18 fail_with(Msf::Module::Failure::BadConfig, 'The LDAP search failed because some matching is not supported for the target attribute type!') + when 19 + fail_with(Msf::Module::Failure::BadConfig, 'A constraint on the operation was not satisfied') when 32 fail_with(Msf::Module::Failure::UnexpectedReply, 'The LDAP search failed because the operation targeted an entity within the base DN that does not exist.') when 33 diff --git a/modules/auxiliary/admin/ldap/change_password.rb b/modules/auxiliary/admin/ldap/change_password.rb new file mode 100755 index 000000000000..f5b18aa55b8a --- /dev/null +++ b/modules/auxiliary/admin/ldap/change_password.rb @@ -0,0 +1,159 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + + include Msf::Auxiliary::Report + include Msf::Exploit::Remote::LDAP + include Msf::OptionalSession::LDAP + + ATTRIBUTE = 'unicodePwd'.freeze + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Change Password', + 'Description' => %q{ + This module allows Active Directory users to change their own passwords, or reset passwords for + accounts they have privileges over. + }, + 'Author' => [ + 'smashery' # module author + ], + 'References' => [ + ['URL', 'https://github.com/fortra/impacket/blob/master/examples/changepasswd.py'], + ['URL', 'https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/6e803168-f140-4d23-b2d3-c3a8ab5917d2'], + ], + 'License' => MSF_LICENSE, + 'Actions' => [ + ['RESET', { 'Description' => "Reset a target user's password, having permissions over their account" }], + ['CHANGE', { 'Description' => "Change the user's password, knowing the existing password" }] + ], + 'DefaultAction' => 'RESET', + 'Notes' => { + 'Stability' => [], + 'SideEffects' => [ IOC_IN_LOGS ], + 'Reliability' => [] + } + ) + ) + + register_options([ + OptString.new('TARGET_USER', [false, 'The user to reset the password of.'], conditions: ['ACTION', 'in', %w[RESET]]), + OptString.new('NEW_PASSWORD', [ true, 'The new password to set for the user' ]) + ]) + end + + def fail_with_ldap_error(message) + ldap_result = @ldap.get_operation_result.table + return if ldap_result[:code] == 0 + + print_error(message) + if ldap_result[:code] == 19 + extra_error = '' + if action.name == 'CHANGE' && !datastore['SESSION'].blank? + # If you're already in a session, you could provide the wrong password, and you get this error + extra_error = ' or incorrect current password' + end + + error = "The password changed failed, likely due to a password policy violation (e.g. not sufficiently complex, matching previous password, or changing the password too often)#{extra_error}" + fail_with(Failure::NotFound, error) + else + validate_query_result!(ldap_result) + end + end + + def ldap_get(filter, attributes: []) + raw_obj = @ldap.search(base: @base_dn, filter: filter, attributes: attributes)&.first + return nil unless raw_obj + + obj = {} + + obj['dn'] = raw_obj['dn'].first.to_s + unless raw_obj['sAMAccountName'].empty? + obj['sAMAccountName'] = raw_obj['sAMAccountName'].first.to_s + end + + unless raw_obj['ObjectSid'].empty? + obj['ObjectSid'] = Rex::Proto::MsDtyp::MsDtypSid.read(raw_obj['ObjectSid'].first) + end + + obj + end + + def run + if action.name == 'CHANGE' + fail_with(Failure::BadConfig, 'Must set USERNAME when changing password') if datastore['USERNAME'].blank? + fail_with(Failure::BadConfig, 'Must set PASSWORD when changing password') if datastore['PASSWORD'].blank? + end + if session.blank? && datastore['USERNAME'].blank? + print_warning("Connecting with an anonymous bind") + end + ldap_connect do |ldap| + validate_bind_success!(ldap) + + if (@base_dn = datastore['BASE_DN']) + print_status("User-specified base DN: #{@base_dn}") + else + print_status('Discovering base DN automatically') + + if (@base_dn = ldap.base_dn) + print_status("#{ldap.peerinfo} Discovered base DN: #{@base_dn}") + else + print_warning("Couldn't discover base DN!") + end + end + @ldap = ldap + + begin + send("action_#{action.name.downcase}") + rescue ::IOError => e + fail_with(Failure::UnexpectedReply, e.message) + end + end + rescue Errno::ECONNRESET + fail_with(Failure::Disconnected, 'The connection was reset.') + rescue Rex::ConnectionError => e + fail_with(Failure::Unreachable, e.message) + rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e + fail_with(Failure::NoAccess, e.message) + rescue Net::LDAP::Error => e + fail_with(Failure::Unknown, "#{e.class}: #{e.message}") + end + + def get_user_obj(username) + obj = ldap_get("(sAMAccountName=#{username})", attributes: ['sAMAccountName', 'ObjectSID']) + if obj.nil? && username.end_with?('$') + obj = ldap_get("(sAMAccountName=#{username}$)", attributes: ['sAMAccountName', 'ObjectSID']) + end + fail_with(Failure::NotFound, "Failed to find sAMAccountName: #{username}") unless obj + + obj + end + + def action_reset + target_user = datastore['TARGET_USER'] + fail_with(Failure::BadConfig, 'Must set TARGET_USER when resetting password') if target_user.blank? + obj = get_user_obj(target_user) + + new_pass = "\"#{datastore['NEW_PASSWORD']}\"".encode('utf-16le').bytes.pack('c*') + unless @ldap.replace_attribute(obj['dn'], ATTRIBUTE, new_pass) + fail_with_ldap_error("Failed to reset the password for #{datastore['TARGET_USER']}.") + end + print_good("Successfully reset password for #{datastore['TARGET_USER']}.") + end + + def action_change + obj = get_user_obj(datastore['USERNAME']) + + new_pass = "\"#{datastore['NEW_PASSWORD']}\"".encode('utf-16le').bytes.pack('c*') + old_pass = "\"#{datastore['PASSWORD']}\"".encode('utf-16le').bytes.pack('c*') + unless @ldap.modify(:dn => obj['dn'], :operations => [[:delete, ATTRIBUTE, old_pass], [:add, ATTRIBUTE, new_pass]]) + fail_with_ldap_error("Failed to reset the password for #{datastore['USERNAME']}.") + end + print_good("Successfully changed password for #{datastore['USERNAME']}.") + end +end From cd780e4339fab7b686386f5be07beb10242e9a56 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Fri, 22 Nov 2024 13:12:38 +1100 Subject: [PATCH 2/5] Added documentation --- .../auxiliary/admin/ldap/change_password.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100755 documentation/modules/auxiliary/admin/ldap/change_password.md diff --git a/documentation/modules/auxiliary/admin/ldap/change_password.md b/documentation/modules/auxiliary/admin/ldap/change_password.md new file mode 100755 index 000000000000..d52a4fe744fc --- /dev/null +++ b/documentation/modules/auxiliary/admin/ldap/change_password.md @@ -0,0 +1,39 @@ +## Introduction + +Allows changing or resetting users' passwords over the LDAP protocol (particularly for Active Directory). + +"Changing" refers to situations where you know the value of the existing password, and send that to the server as part of the password modification. +"Resetting" refers to situations where you may not know the value of the existing password, but by virtue of your permissions over the target account, you can force-change the password without necessarily knowing it. + +Note that users can typically not reset their own passwords (unless they have very high privileges), but can usually change their password as long as they know the existing one. + +This module works with existing sessions (or relaying), especially for Resetting, wherein the target's password is not required. + +## Actions + +- `RESET` - Reset the target's password without knowing the existing one (requires appropriate permissions) +- `CHANGE` - Change the user's password, knowing the existing one. + +## Options + +The required options are based on the action being performed: + +- When resetting a password, you must specify the `TARGET_USER` +- When changing a password, you must specify the `USERNAME` and `PASSWORD`, even if using an existing session (since the API requires both of these to be specified, even for open SMB sessions) +- The `NEW_PASSWORD` option must always be provided + +**USERNAME** + +The username to use to authenticate to the server. Required for changing a password, even if using an existing session. + +**PASSWORD** + +The password to use to authenticate to the server, prior to performing the password modification. Required for changing a password, even if using an existing session (since the server requires proof that you know the existing password). + +**TARGET_USER** + +For resetting passwords, the user account for which to reset the password. The authenticated account (username) must have privileges over the target user (e.g. Ownership, or the `User-Force-Change-Password` extended right) + +**NEW_PASSWORD** + +The new password to set. \ No newline at end of file From ae61d0a9d62dd30f3bf798fc408a8357bcebbb1a Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Fri, 22 Nov 2024 13:35:43 +1100 Subject: [PATCH 3/5] MSFTidy changes --- modules/auxiliary/admin/ldap/change_password.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) mode change 100755 => 100644 modules/auxiliary/admin/ldap/change_password.rb diff --git a/modules/auxiliary/admin/ldap/change_password.rb b/modules/auxiliary/admin/ldap/change_password.rb old mode 100755 new mode 100644 index f5b18aa55b8a..372e4e838eff --- a/modules/auxiliary/admin/ldap/change_password.rb +++ b/modules/auxiliary/admin/ldap/change_password.rb @@ -17,7 +17,7 @@ def initialize(info = {}) info, 'Name' => 'Change Password', 'Description' => %q{ - This module allows Active Directory users to change their own passwords, or reset passwords for + This module allows Active Directory users to change their own passwords, or reset passwords for accounts they have privileges over. }, 'Author' => [ @@ -90,7 +90,7 @@ def run fail_with(Failure::BadConfig, 'Must set PASSWORD when changing password') if datastore['PASSWORD'].blank? end if session.blank? && datastore['USERNAME'].blank? - print_warning("Connecting with an anonymous bind") + print_warning('Connecting with an anonymous bind') end ldap_connect do |ldap| validate_bind_success!(ldap) @@ -151,7 +151,7 @@ def action_change new_pass = "\"#{datastore['NEW_PASSWORD']}\"".encode('utf-16le').bytes.pack('c*') old_pass = "\"#{datastore['PASSWORD']}\"".encode('utf-16le').bytes.pack('c*') - unless @ldap.modify(:dn => obj['dn'], :operations => [[:delete, ATTRIBUTE, old_pass], [:add, ATTRIBUTE, new_pass]]) + unless @ldap.modify(dn: obj['dn'], operations: [[:delete, ATTRIBUTE, old_pass], [:add, ATTRIBUTE, new_pass]]) fail_with_ldap_error("Failed to reset the password for #{datastore['USERNAME']}.") end print_good("Successfully changed password for #{datastore['USERNAME']}.") From 75a334ca0a15a06c918e8ca1bc626e3f298eb596 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Fri, 6 Dec 2024 16:03:21 +1100 Subject: [PATCH 4/5] Changes from code review --- .../auxiliary/admin/ldap/change_password.md | 2 +- .../auxiliary/admin/ldap/change_password.rb | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/documentation/modules/auxiliary/admin/ldap/change_password.md b/documentation/modules/auxiliary/admin/ldap/change_password.md index d52a4fe744fc..7e38236d5b3c 100755 --- a/documentation/modules/auxiliary/admin/ldap/change_password.md +++ b/documentation/modules/auxiliary/admin/ldap/change_password.md @@ -19,7 +19,7 @@ This module works with existing sessions (or relaying), especially for Resetting The required options are based on the action being performed: - When resetting a password, you must specify the `TARGET_USER` -- When changing a password, you must specify the `USERNAME` and `PASSWORD`, even if using an existing session (since the API requires both of these to be specified, even for open SMB sessions) +- When changing a password, you must specify the `USERNAME` and `PASSWORD`, even if using an existing session (since the API requires both of these to be specified, even for open LDAP sessions) - The `NEW_PASSWORD` option must always be provided **USERNAME** diff --git a/modules/auxiliary/admin/ldap/change_password.rb b/modules/auxiliary/admin/ldap/change_password.rb index 372e4e838eff..3f8b1c8c13ae 100644 --- a/modules/auxiliary/admin/ldap/change_password.rb +++ b/modules/auxiliary/admin/ldap/change_password.rb @@ -77,10 +77,6 @@ def ldap_get(filter, attributes: []) obj['sAMAccountName'] = raw_obj['sAMAccountName'].first.to_s end - unless raw_obj['ObjectSid'].empty? - obj['ObjectSid'] = Rex::Proto::MsDtyp::MsDtypSid.read(raw_obj['ObjectSid'].first) - end - obj end @@ -88,8 +84,10 @@ def run if action.name == 'CHANGE' fail_with(Failure::BadConfig, 'Must set USERNAME when changing password') if datastore['USERNAME'].blank? fail_with(Failure::BadConfig, 'Must set PASSWORD when changing password') if datastore['PASSWORD'].blank? + elsif action.name == 'RESET' + fail_with(Failure::BadConfig, 'Must set TARGET_USER when resetting password') if datastore['TARGET_USER'].blank? end - if session.blank? && datastore['USERNAME'].blank? + if session.blank? && datastore['USERNAME'].blank? && datastore['LDAP::Auth'] != Msf::Exploit::Remote::AuthOption::SCHANNEL print_warning('Connecting with an anonymous bind') end ldap_connect do |ldap| @@ -103,7 +101,7 @@ def run if (@base_dn = ldap.base_dn) print_status("#{ldap.peerinfo} Discovered base DN: #{@base_dn}") else - print_warning("Couldn't discover base DN!") + fail_with(failure::UnexpectedReply, "Couldn't discover base DN!") end end @ldap = ldap @@ -120,15 +118,14 @@ def run fail_with(Failure::Unreachable, e.message) rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e fail_with(Failure::NoAccess, e.message) + rescue Rex::Proto::LDAP::LdapException => e + fail_with(Failure::NoAccess, e.message) rescue Net::LDAP::Error => e fail_with(Failure::Unknown, "#{e.class}: #{e.message}") end def get_user_obj(username) - obj = ldap_get("(sAMAccountName=#{username})", attributes: ['sAMAccountName', 'ObjectSID']) - if obj.nil? && username.end_with?('$') - obj = ldap_get("(sAMAccountName=#{username}$)", attributes: ['sAMAccountName', 'ObjectSID']) - end + obj = ldap_get("(sAMAccountName=#{ldap_escape_filter(username)})", attributes: ['sAMAccountName']) fail_with(Failure::NotFound, "Failed to find sAMAccountName: #{username}") unless obj obj @@ -136,7 +133,6 @@ def get_user_obj(username) def action_reset target_user = datastore['TARGET_USER'] - fail_with(Failure::BadConfig, 'Must set TARGET_USER when resetting password') if target_user.blank? obj = get_user_obj(target_user) new_pass = "\"#{datastore['NEW_PASSWORD']}\"".encode('utf-16le').bytes.pack('c*') From a708f8c7f375156b84a6331f12e2e19d0dbc0712 Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Fri, 6 Dec 2024 16:47:25 -0500 Subject: [PATCH 5/5] Fix a trivial typo --- modules/auxiliary/admin/ldap/change_password.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/auxiliary/admin/ldap/change_password.rb b/modules/auxiliary/admin/ldap/change_password.rb index 3f8b1c8c13ae..57f6ae44e2f0 100644 --- a/modules/auxiliary/admin/ldap/change_password.rb +++ b/modules/auxiliary/admin/ldap/change_password.rb @@ -101,7 +101,7 @@ def run if (@base_dn = ldap.base_dn) print_status("#{ldap.peerinfo} Discovered base DN: #{@base_dn}") else - fail_with(failure::UnexpectedReply, "Couldn't discover base DN!") + fail_with(Failure::UnexpectedReply, "Couldn't discover base DN!") end end @ldap = ldap