Over the past few weeks, we’ve been experimenting with detection of Active Directory Certificate Services abuse using native logging and not by relying on EDR telemetry, specifically with regards to the identification, issuance and use of certificates with alternative names (SANs). Further details of the attack specifics can be found from the excellent research provided by Will Schroeder and Lee Christensen of Specterops and in this article we’re specifically hunting for the abuse of certificates that allow a subjectAltName (SAN) to be provided.
Note: To receive the relevant AD CS events, certificate service logging will need to be enabled. At a minimum, event 4886/4887 will be required (discussed below), but other data is available and further details can be found in this Microsoft article.
AD CS Events
If you’ve looked into certificate services logs (or the lack thereof) when attempting to detect AD CS abuse, you’ll likely be aware that the logs leave much to be desired.
Let’s just take a quick look at two key events.
- Event ID 4886 (Certificate Services received a certificate request)
- Event ID 4887 (Certificate Services approved a certificate request and issued a certificate)
The key data available from each event, includes:
- The AD CS Server (Computer)
- The certificate requester (EventData > Requester)
- The workstation from where the certificate request was made (EventData > Attributes)
The first issue we came across was how to map these events to active sessions.
The method devised was to check for the presence for either event 4886 or 4887 (certificate issuance events) around the same timeframe that a Kerberos TGT was requested, with both events occurring on the same workstation.
Kerberos TGT Request
Before we continue too far down this path, lets see an example of a TGT request (event 4768), and the data contained therein.
The key data available from this event, includes:
- The domain controller involved in the TGT issuance (Computer)
- The account making the TGT request (TargetUserName)
- The IpAddress from where the certificate request was made (IpAddress)
- AD CS issuer (CertIssuerName)
- Certificate Serial Number (CertSerialNumber)
This is where we ran into our next issue; how to map/correlate these completely different events.
The certificate events included a hostname, but the Kerberos TGT events include an IP address of the associated host.
We needed Event ID DNS…
Event ID DNS
This method utilises the Heartbeat table within the Azure Log Analytics workspace.
Background on the Heartbeat table can be found here, but essentially any system that has the AMA agent installed will have health logs (and useful data) registered here.
This table includes a wealth of information, including the much needed hostname and IP address mapping.
The complete query demonstrated in the above screenshot.
Heartbeat | mv-expand ComputerPrivateIPs | extend ComputerPrivateIPs = tostring(ComputerPrivateIPs) | extend Computer = split(Computer, ".", 0) | mv-expand Computer | extend Computer = tostring(Computer) | distinct ComputerPrivateIPs, Computer
We now have a table that could be queried to locate a hostname should we know an IP address, and inversely an IP address should we know the hostname.
At this stage we’re able to map events based on a hostname or IP address. This gives us the power to look for certificate based events (4886 or 4887) which include the hostname, perform a lookup of the hostname to get the associated IP address and then look for a Kerberos TGT event (4768) originating from the same host within a given period, 24 hours in this example although that can be changed.
If a match is identified, we then look to see if there’s a difference between the account that made the certificate request and the account name for which the TGT was issued.
A few things to note:
- The logic presented in this article will only be able to identify SAN abuse if all activity (certificate and TGT request) is made on Host A and then used on Host A. We’re looking for a difference in the certificate requester name and the use of a certificate on the same host, but with an alternative name.
- We’re using the KQL arg_min aggregation function to only return the first identified use of a certificate (based on the unique serial number), where this is used to request a TGT.
The Completed Query
// Period to run query against let QueryLookBack = 7d; // Create a new variable IpHostNameLookup let IpHostNameLookup = ( // Heartbeat table is used Heartbeat // Expand dynamic array into multiple records and convert to string representation | mv-expand ComputerPrivateIPs | extend ComputerPrivateIPs = tostring(ComputerPrivateIPs) // Take hostname only (remove domain value) | extend Computer = split(Computer, ".", 0) // Expand dynamic array into multiple records and convert to string representation | mv-expand Computer | extend Computer = tostring(Computer) // Deduplicate results based on IP and hostname | distinct ComputerPrivateIPs, Computer ); // Create a new variable CertRequesterEvents let CertRequesterEvents = ( // SecurityEvent table is used SecurityEvent // Time value defined in QueryLookBack variable | where TimeGenerated >= ago(QueryLookBack) // Event ID 4886 (Certificate Services received a certificate request) and 4887 (Certificate Services approved a certificate request and issued a certificate) are queried | where EventID in (4886, 4887) // The hostname from where the certificate request was made. Extract from the Attributes field and data after the colon is kept | extend Computer = split(Attributes, ":", 1) // Expand dynamic array into multiple records | mv-expand Computer // Take hostname only (remove domain value) | extend Computer = split(Computer, ".", 0) // Expand dynamic array into multiple records and convert to string representation | mv-expand Computer | extend Computer = tostring(Computer) // Remove the domain information from requester (leave just username) | extend Requester = split(Requester, "\\", 1) // Expand dynamic array into multiple records and convert to string representation | mv-expand Requester | extend Requester = tostring(Requester) // Make sure the Computer field includes data | where isnotempty(Computer) // Aggregate data (the account name making a certificate request) into 1 minute second periods based on Computer name | summarize CertRequest=make_set(Requester) by Computer, bin(TimeGenerated, 1m) // Expand dynamic array into multiple records and convert to string representation | mv-expand CertRequest | extend CertRequest = tostring(CertRequest) | project TimeGenerated, CertRequest, Computer ); // Create a new variable CertTgtEvents let CertTgtEvents = ( // SecurityEvent table is used SecurityEvent // Time value defined in QueryLookBack variable | where TimeGenerated >= ago(QueryLookBack) // Event ID 4768 (a Kerberos authentication ticket (TGT) was requested) | where EventID in (4768) // Extract data between CertSerialNumber "> and < and put results in a field called CertSerial | parse EventData with * 'CertSerialNumber">' CertSerial '<' * // Make sure the CertSerial field includes data | where isnotempty(CertSerial) // Extract from the IpAddress field, data after the third colon is kept | extend IpAddress = split(IpAddress, ":", 3) // Expand dynamic array into multiple records and convert to string representation | mv-expand IpAddress | extend IpAddress = tostring(IpAddress) // Exclude computer accounts from results | where TargetUserName !has '$@' | where TargetUserName !endswith '$' // Aggregate data on IP addresses that have seen TGT request activity, matched on an account name and certificate serial number, on a 1 minute period | summarize CertKrbTgtRequesterIpAddress=make_set(IpAddress) by TargetUserName, CertSerial, bin(TimeGenerated, 1m) // Expand dynamic array into multiple records and convert to string representation | mv-expand CertKrbTgtRequesterIpAddress | extend CertKrbTgtRequesterIpAddress = tostring(CertKrbTgtRequesterIpAddress) | project TimeGenerated, TargetUserName, CertKrbTgtRequesterIpAddress, CertSerial ); // IpHostNameLookup dataset is used IpHostNameLookup // Inner join used on CertRequesterEvents dataset, results based on matching Computer fields | join kind=inner ( CertRequesterEvents ) on Computer | project CertReqTime = TimeGenerated, CertRequest, Computer, IpAddress = ComputerPrivateIPs // Inner join used on CertTgtEvents dataset, results based on matching IpAddress fields | join kind=inner ( CertTgtEvents ) on $left.IpAddress == $right.CertKrbTgtRequesterIpAddress | project CertReqTime, CertRequest, Computer, IpAddress, CertTGTReqTime = TimeGenerated, CertTgtUsername = TargetUserName, CertSerial // TGT request must occur within a 24 hour period of a certificate request | where (CertTGTReqTime - CertReqTime) between (0h .. 24h) // Only match on where the account names are different (between certificate request and TGT request) | where CertRequest != CertTgtUsername // Take just the first match (based on time) where the certificate serial is the unique identifier | summarize arg_min(CertTGTReqTime, *) by CertSerial | project CertReqTime, CertTGTReqTime, CertSerial, Computer, IpAddress, CertRequest, CertTgtUsername | sort by CertReqTime asc
In our testing environment we can see that four events have been identified (fields explained below). For example, the first hit indicates that a certificate was requested by the user sysmonsvc on workstation az-wks-06. Around three minutes later a TGT event occurred (which included certificate attributes), for the user smorrison. Neither event is necessarily suspicious in itself, but as we see a difference in account name occurring on the same system (within a given timeframe), it provides a great reference point for further analysis.
- The time at which a certificate request was made (4886/4887 event data)
- The time at which a certificate was used to request a TGT (4768 event data)
- The certificate serial number
- Computer and IpAddress
- The host on which both the certificate (hostname) and TGT (IP address) were requested
- The account that requested the certificate (4886/4887 event data)
- The account for which a TGT was requested and as detailed within the TargetUserName field (4768 event data)
Note: Assumptions have been made in that a user will be assigned, or have regular access to a workstation, i.e. hot desking isn’t used within the environment. Several different accounts/authentication events on a single host will skew results.
This query has been tested on limited datasets and therefore we’d love to hear from anyone that can test this on a larger sample!
Needless to say there is still much work to be completed here, however this does show that all is not lost if all we have are native logs and some creative thinking…