← Blog · · df00tech

Detecting AD CS Abuse (T1649): ESC1, ESC6, ESC8 and Shadow Credentials with KQL and SPL

Detection Engineering MITRE ATT&CK KQL SPL Active Directory

Most SOCs have decent coverage for Kerberos ticket attacks and DCSync, and almost none for Active Directory Certificate Services. That gap matters because AD CS abuse — mapped to T1649: Steal or Forge Authentication Certificates — gives an attacker a credential that is not a password. It survives a password reset, it survives disabling the account in some configurations, and it stays valid until the certificate expires or you stand up a CRL entry for it. Certificate validity on a typical user template is measured in years.

The attack classes (ESC1 through ESC14, catalogued by SpecterOps) differ in setup but converge on the same outcome: request a certificate that authenticates as somebody else, then use PKINIT to get a TGT as that identity. This post gives you the detections for the ESC paths that actually show up in incidents, in both KQL for Microsoft Sentinel and SPL for Splunk.

Prerequisite: your CA is probably not logging

Every query below depends on telemetry that is off by default. Fix this before you write a single rule:

  • CA auditing. On each issuing CA: certutil -setreg CA\AuditFilter 127, restart certsvc, and enable Audit Certification Services (Advanced Audit Policy → Object Access). This produces 4886 (request received), 4887 (certificate issued), 4888 (request denied), 4899/4900 (template changed), 4882/4885/4890 (CA security and audit settings changed).
  • DS Access auditing on domain controllers with a SACL for write access on user and computer objects, which produces 5136 directory-object-modified events.
  • IIS logs from the CA web enrollment host (W3CIISLog in Sentinel) if certsrv is published — that is the ESC8 surface.
  • 4768 Kerberos TGT requests from all DCs. The certificate fields in this event are the single highest-fidelity signal in the whole kill chain.

Detection 1: ESC1 — requester-supplied SAN does not match the requester

ESC1 is a template that allows enrollees to supply the subject alternative name and includes a client authentication EKU. An attacker enrolls as lowpriv but sets SAN:[email protected]. The CA happily issues it. The tell is the mismatch between the authenticated requester and the identity embedded in the request.

// Sentinel: ESC1-style identity mismatch in issued certificates
SecurityEvent
| where EventID in (4886, 4887)
| extend Requester  = extract(@"Requester:\s*([^\r\n]+)", 1, EventData),
         Attributes = extract(@"Attributes:\s*([\s\S]*?)(?:\r?\n\r?\n|$)", 1, EventData),
         RequestId  = extract(@"Request ID:\s*([^\r\n]+)", 1, EventData)
| extend Template     = extract(@"CertificateTemplate:([^\s\r\n]+)", 1, Attributes),
         RequestedUpn = tolower(extract(@"(?i)upn=([^&\s\r\n]+)", 1, Attributes)),
         RequestedDns = tolower(extract(@"(?i)dns=([^&\s\r\n]+)", 1, Attributes))
| where isnotempty(RequestedUpn) or isnotempty(RequestedDns)
| extend RequesterUser  = tolower(tostring(split(Requester, "\\")[1])),
         RequestedUser  = tolower(tostring(split(RequestedUpn, "@")[0]))
| where isnotempty(RequestedUpn)
| where RequesterUser != RequestedUser
     and RequesterUser != strcat(RequestedUser, "$")
| project TimeGenerated, Computer, EventID, RequestId, Requester, RequestedUpn, Template
| order by TimeGenerated desc
# Splunk: same logic against Windows CA events
index=wineventlog (EventCode=4886 OR EventCode=4887)
| rex field=_raw "Requester:\s*(?<requester>[^\r\n]+)"
| rex field=_raw "Attributes:\s*(?<attributes>[\s\S]*?)(\r?\n\r?\n|$)"
| rex field=attributes "CertificateTemplate:(?<template>[^\s\r\n]+)"
| rex field=attributes "(?i)upn=(?<requested_upn>[^&\s\r\n]+)"
| where isnotnull(requested_upn)
| eval requester_user=lower(mvindex(split(requester,"\\"),1)),
       requested_user=lower(mvindex(split(requested_upn,"@"),0))
| where requester_user!=requested_user AND requester_user!=requested_user."$"
| table _time host EventCode requester requested_upn template
| sort - _time

Tune this by allowlisting your enrollment agents and MDM/NDES service accounts, which legitimately request certificates on behalf of other principals. Everything else that fires here is either a misconfiguration worth fixing or an attack worth paging on.

Detection 2: PKINIT authentication from an account that has never used a certificate

This is the detection to build first if you only build one. Event 4768 carries CertIssuerName, CertSerialNumber, and CertThumbprint whenever the TGT was obtained with a certificate. In most environments, the set of accounts that legitimately authenticate this way is small and stable — smart card users, specific service identities. A privileged account suddenly appearing with a populated certificate thumbprint is pass-the-certificate.

// Sentinel: first-time certificate-based (PKINIT) TGT per account
let lookback = 30d;
let known = SecurityEvent
    | where TimeGenerated between (ago(lookback) .. ago(1d))
    | where EventID == 4768 and isnotempty(CertThumbprint) and CertThumbprint != "-"
    | distinct TargetUserName;
SecurityEvent
| where TimeGenerated > ago(1d)
| where EventID == 4768
| where isnotempty(CertThumbprint) and CertThumbprint != "-"
| where TargetUserName !in~ (known)
| project TimeGenerated, Computer, TargetUserName, TargetDomainName, IpAddress,
          CertIssuerName, CertSerialNumber, CertThumbprint, Status
| order by TimeGenerated asc
# Splunk: PKINIT first-seen by account
index=wineventlog EventCode=4768 earliest=-30d
| eval pkinit=if(isnotnull(Certificate_Thumbprint) AND Certificate_Thumbprint!="-","yes","no")
| search pkinit="yes"
| stats earliest(_time) as first_seen, latest(_time) as last_seen,
        dc(Certificate_Thumbprint) as thumbprints,
        values(Certificate_Issuer_Name) as issuers,
        values(src_ip) as src_ips, count by user
| where first_seen > relative_time(now(),"-1d")
| convert ctime(first_seen) ctime(last_seen)

Correlate any hit with the 4887 from Detection 1 using the certificate serial number. A matching pair — issued certificate with a mismatched SAN, then a PKINIT TGT for the SAN identity — is a confirmed ESC1 chain and should go straight to containment: revoke the certificate, publish the CRL, and treat the target account as compromised. A password reset alone will not help you. Follow the post-exploitation activity through your pass-the-ticket and lateral movement coverage.

Shadow credentials are the modern replacement for RBCD abuse: write a key credential to a victim object with GenericWrite or WriteProperty, then authenticate as that object using the attacker-held private key. Tools like Whisker and Certipy's shadow auto do this in one command. It surfaces as a 5136 modification of msDS-KeyCredentialLink, and maps to T1098: Account Manipulation.

// Sentinel: msDS-KeyCredentialLink written to a user or computer object
SecurityEvent
| where EventID == 5136
| extend AttributeName = extract(@"LDAP Display Name:\s*([^\r\n]+)", 1, EventData),
         ObjectDN      = extract(@"DN:\s*([^\r\n]+)", 1, EventData),
         OperationType = extract(@"Type:\s*([^\r\n]+)", 1, EventData)
| where AttributeName =~ "msDS-KeyCredentialLink"
| project TimeGenerated, Computer, SubjectAccount, SubjectUserName, ObjectDN, OperationType
| order by TimeGenerated desc
# Splunk: shadow credential writes
index=wineventlog EventCode=5136
| rex field=_raw "LDAP Display Name:\s*(?<ldap_attribute>[^\r\n]+)"
| rex field=_raw "DN:\s*(?<object_dn>[^\r\n]+)"
| rex field=_raw "Type:\s*(?<operation>[^\r\n]+)"
| search ldap_attribute="msDS-KeyCredentialLink"
| stats count values(operation) as operations values(object_dn) as targets by _time host user
| sort - _time

Legitimate writers of this attribute are Windows Hello for Business enrollment and Azure AD Connect-adjacent flows, where the subject account is the object itself or a known provisioning identity. A user account writing a key credential onto another user or a domain controller object is the attack.

Detection 4: Template and CA configuration tampering (ESC4, ESC6, ESC7)

If an attacker has write access to a certificate template, they do not need to find a vulnerable one — they create one. ESC4 is template ACL abuse; ESC6 is the EDITF_ATTRIBUTESUBJECTALTNAME2 flag on the CA, which makes every template behave like ESC1; ESC7 is abuse of the ManageCA right. All three leave configuration-change audit trails, and all three overlap with T1484.001: Group Policy Modification tradecraft in that the change is the attack.

// Sentinel: AD CS configuration and template changes, bursted by actor
SecurityEvent
| where EventID in (4882, 4885, 4890, 4896, 4899, 4900)
| extend ChangeType = case(
    EventID == 4882, "CA security permissions changed",
    EventID == 4885, "CA audit filter changed",
    EventID == 4890, "CA certificate manager settings changed",
    EventID == 4896, "CA rows deleted from database",
    EventID == 4899, "Certificate template updated",
    "Certificate template security updated")
| summarize Changes = count(), Types = make_set(ChangeType), Events = make_set(EventID)
        by Computer, SubjectAccount, bin(TimeGenerated, 1h)
| order by Changes desc
# Splunk: AD CS config change surface
index=wineventlog EventCode IN (4882,4885,4890,4896,4899,4900)
| eval change_type=case(
    EventCode==4882,"ca_permissions", EventCode==4885,"ca_audit_filter",
    EventCode==4890,"ca_manager_settings", EventCode==4896,"ca_rows_deleted",
    EventCode==4899,"template_updated", EventCode==4900,"template_security")
| stats count, values(change_type) as changes, dc(change_type) as change_kinds by host, user, date_hour
| where change_kinds >= 1 AND count > 0
| sort - count

Template changes outside a change window, or a 4900 immediately followed by a 4887 for the same template, is the ESC4 sequence end to end. Pair this with a scheduled compliance check that reads the CA registry flags, because ESC6 enabled through certutil -setreg policy\EditFlags is a registry write — catch it in your endpoint telemetry as well.

Detection 5: ESC8 — NTLM relay to CA web enrollment

ESC8 relays machine-account NTLM authentication to the CA's HTTP enrollment endpoint and requests a certificate for that machine — commonly a domain controller, which yields DCSync. Two signals stack here: POSTs to the enrollment pages from hosts that have no business enrolling, and 4886 requests where the requester is a machine account arriving over the network.

// Sentinel: enrollment POSTs correlated with machine-account certificate requests
let webEnroll = W3CIISLog
    | where csUriStem has_any ("/certsrv/certfnsh.asp", "/certsrv/certrqxt.asp", "/certsrv/certrqbi.asp")
    | where csMethod == "POST"
    | project TimeGenerated, CaHost = Computer, ClientIp = cIP, csUriStem, csUserName;
let machineReqs = SecurityEvent
    | where EventID == 4886
    | extend Requester = extract(@"Requester:\s*([^\r\n]+)", 1, EventData)
    | where Requester endswith "$"
    | project ReqTime = TimeGenerated, CaHost = Computer, Requester;
webEnroll
| join kind=inner (machineReqs) on CaHost
| where abs(datetime_diff("second", ReqTime, TimeGenerated)) <= 30
| project TimeGenerated, CaHost, ClientIp, csUserName, Requester, csUriStem
# Splunk: web enrollment POSTs from unexpected clients
index=iis sourcetype=ms:iis:auto cs_uri_stem IN ("/certsrv/certfnsh.asp","/certsrv/certrqxt.asp")
       cs_method=POST
| stats count, values(cs_username) as enroll_users, values(cs_uri_stem) as endpoints
        by _time, host, c_ip
| search NOT [| inputlookup adcs_known_enrollment_clients.csv | fields c_ip ]
| sort - count

Tooling artifacts on the endpoint

Catch the operator's tools as a second layer. Certipy, Certify, and certutil all have recognisable invocations, and Rubeus requesting a TGT with a certificate is the conversion step from certificate to ticket.

// Defender XDR / Sentinel: AD CS tooling and enrollment from the command line
DeviceProcessEvents
| where Timestamp > ago(7d)
| where (FileName in~ ("certutil.exe", "certreq.exe")
         and ProcessCommandLine has_any ("-template", "-config", "-exportPFX", "-enroll"))
     or ProcessCommandLine has_any ("certipy", "Certify.exe", "find /vulnerable",
                                    "/getcert", "shadow auto", "Whisker.exe",
                                    "asktgt", "/certificate:", "PassTheCert")
| project Timestamp, DeviceName, AccountName, FileName, ProcessCommandLine,
          InitiatingProcessFileName, InitiatingProcessCommandLine
| order by Timestamp desc

Expect noise from legitimate certutil use in build and PKI automation — scope by account and parent process rather than deleting the rule. Renamed binaries will slip past the filename clauses, which is exactly why Detections 1 and 2 are the authoritative layer: they watch the CA and the KDC, not the attacker's choice of executable. The same reasoning applies to signed binary proxy execution coverage generally.

Triage order

  1. Identify the certificate. Pull the serial number from 4887 and the thumbprint from 4768. Without these you cannot revoke.
  2. Determine the impersonated identity from the SAN, and establish what that identity can reach.
  3. Revoke and publish. Revoke the certificate on the CA and force CRL publication. Deleting the user account does not invalidate an issued certificate.
  4. Fix the template. Remove enrollee-supplied subject, require manager approval, or strip the client auth EKU. Clear EDITF_ATTRIBUTESUBJECTALTNAME2 if set.
  5. Hunt backwards. Query 4887 across the full retention window for the same template and the same requester — ESC1 is cheap to repeat, and operators usually mint more than one certificate.

AD CS detection is unusual in that the highest-value rules are also the cheapest: two queries over 4887 and 4768 will catch the overwhelming majority of real certificate abuse, provided the CA is auditing at all. Turn on AuditFilter first, then ship the rules.

Get new detections in your inbox

New ATT&CK coverage plus CISA KEV / CVE detection rules, roughly weekly. No spam, unsubscribe anytime.