Skip to content

Instantly share code, notes, and snippets.

@Cyb3r-Monk
Last active November 2, 2020 17:35
Show Gist options
  • Save Cyb3r-Monk/818eb8b965a4cd4ff67028ba0f81fdd8 to your computer and use it in GitHub Desktop.
Save Cyb3r-Monk/818eb8b965a4cd4ff67028ba0f81fdd8 to your computer and use it in GitHub Desktop.
Kerberoasting Detection with KQL
// create a whitelist that consists of your AD domain names. ex: ("contoso.com","contoso.local","contoso.dmz")
let _whitelist_ServiceName = dynamic (["contoso.com","contoso.local","contoso.dmz"]);
let _fromDate = ago(8d);
let _thruDate = now();
let _min_dcount_threshold = 6;
let _period =2h; // this value should also be used as the rule frequency.
// If an account requested more than <_min_dcount_threshold> service tickets during the last <_period> hour, 
// we need to check if it is suspicious or not by looking at historical data for the same user. 
// STEP 1: Create a list of accounts to be checked based on their requests during the last <_period> hour.
// This approach also increases the rule performance as only the suspicious accounts are checked historically.
let _accounts_to_be_checked =
    SecurityEvent
    | where TimeGenerated > ago(_period)
    | where EventID == 4769
    | parse EventData with * 'Status">' Status "<" *
    | where Status == '0x0'
    | parse EventData with * 'ServiceName">' ServiceName "<" *
    | where ServiceName !contains "$" and ServiceName !contains "krbtgt"
    | where ServiceName !in~ (_whitelist_ServiceName )
    | parse EventData with * 'TargetUserName">' TargetUserName "<" *
    | where TargetUserName !contains ServiceName
    | parse EventData with * 'TicketEncryptionType">' TicketEncryptionType "<" *
    | where TicketEncryptionType in~ ('0x17','0x18') //these are the weak encryption types.
    | parse EventData with * 'TicketOptions">' TicketOptions "<" *
    | parse EventData with * 'IpAddress">::ffff:' ClientIPAddress "<" *
    | summarize dcount(ServiceName) by TargetUserName
    | where dcount_ServiceName > _min_dcount_threshold
    | summarize make_list(TargetUserName);
// STEP 2: Create a time-series statistics per day for each account in the list, for the last 8 days.
let baseData = materialize(
    SecurityEvent
    | where TimeGenerated > _fromDate
    | where EventID == 4769
    | parse EventData with * 'Status">' Status "<" *
    | where Status == '0x0'
    | parse EventData with * 'ServiceName">' ServiceName "<" *
    | where ServiceName !contains "$" and ServiceName !contains "krbtgt"
    | where ServiceName !in~ (_whitelist_ServiceName )
    | parse EventData with * 'TargetUserName">' TargetUserName "<" *
    | where TargetUserName in~ (_accounts_to_be_checked) // only the accounts in the list will be analyzed.
    | parse EventData with * 'TicketEncryptionType">' TicketEncryptionType "<" *
    | where TicketEncryptionType in~ ('0x17','0x18')
    | parse EventData with * 'TicketOptions">' TicketOptions "<" *
    | parse EventData with * 'IpAddress">::ffff:' ClientIPAddress "<" *
    //creating the time-series stats
    | make-series dcount(ServiceName) default=0  on TimeGenerated in range(_fromDate, _thruDate, 1d)  by TargetUserName, ClientIPAddress 
    | extend avg = todouble(series_stats_dynamic(dcount_ServiceName).avg )
    );
// STEP 3: Find outliers(anomalies) in the time-series data and make a list of suspicious accounts.
let AnomTargetUserNames = (
    baseData
    | extend outliers = series_outliers(dcount_ServiceName)
    | mvexpand dcount_ServiceName, TimeGenerated, outliers to typeof(double)
    // dcount must be at least greater than the threshold. otherwise small numbers can also be an outlier.
	// ex: 1,1,1,1,5 -> 5 is an outlier but probably not suspicious in big environment
    | where dcount_ServiceName > _min_dcount_threshold
    // time-series function rounds the time information and displays the time info as the startofday like '2020-08-23T00:00:00.0000000Z'. 
	// check if the anomlay happened on the current day.
    | where TimeGenerated >= startofday(now())
    // we are looking for positive outliers, meaning increase in the volume.
    // this threshold can be adjusted based on the number of false positives or false negatives
    | where outliers > 1.5
    // we are looking for big outliers. Top 25 outliers should be more than enough. 
    | top 25 by outliers desc
    | summarize make_list(TargetUserName)
    );
// STEP 4: Anomaly is found but it's not clear when it happened exactly and there is not enough information for incident response.
//         Get last <_period> of data and dcount of service names, set of service names that have been requested for each account in the anomalous account list.
//         Join with the baseData as it has both avg values and other details of the anomaly. 
//         Avg value will also be used for preventing the rule from triggering the same alert again.  
//         We already found the anomalous users but we need to check if the anomaly happened in the last <_period> to prevent duplicate incidents.
SecurityEvent
| where TimeGenerated >= ago(_period)
| where EventID == 4769
| parse EventData with * 'Status">' Status "<" *
| where Status == '0x0'
| parse EventData with * 'ServiceName">' ServiceName "<" *
| where ServiceName !contains "$" and ServiceName !contains "krbtgt"
| where ServiceName !in~ (_whitelist_ServiceName )
| parse EventData with * 'TargetUserName">' TargetUserName "<" *
| where TargetUserName in (AnomTargetUserNames) // checking if the user is in the list of anomolous users
| parse EventData with * 'TicketEncryptionType">' TicketEncryptionType "<" *
| where TicketEncryptionType in~ ('0x17','0x18')
| parse EventData with * 'TicketOptions">' TicketOptions "<" *
| parse EventData with * 'IpAddress">::ffff:' ClientIPAddress "<" *
// Calculate the dcount of service tickets, create the set of service tickets requested
| summarize start_Time=min(TimeGenerated), end_Time=max(TimeGenerated), ServiceNameCount_LastPeriod = dcount(ServiceName), ServiceNameSet_LastPeriod = makeset(ServiceName) by TargetUserName
// Now join this with the baseData to display the details of the anomaly.
| join kind=leftouter baseData on TargetUserName
| where ServiceNameCount_LastPeriod > avg + 2 // this is a simple check to see if the anomaly happened in the last period. 
// Display the columns we need
| project start_Time, end_Time, ClientIPAddress, TargetUserName, ServiceNameCount_Avg = avg, ServiceNameCount_LastPeriod, dcount_ServiceName, ServiceNameSet_LastPeriod
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment