Detection rules › Kusto

T1558.003 - Kerberoasting

Group by
TargetUserName
Author
Cyb3rMonk
Source
github.com/Cyb3r-Monk/Threat-Hunting-and-Detection

Detects kerberoasting by using time-series analysis functions. Highly accurate in big environments. Step by step explanation is in the query to make it easy to understand.

MITRE ATT&CK coverage

References

Event coverage

Rule body kusto

// 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

Stages and Predicates

Parameters

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;

Let binding: _accounts_to_be_checked

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')
    | 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);

Derived from _whitelist_ServiceName, _min_dcount_threshold, _period.

Let binding: baseData

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)
    | parse EventData with * 'TicketEncryptionType">' TicketEncryptionType "<" *
    | where TicketEncryptionType in~ ('0x17','0x18')
    | parse EventData with * 'TicketOptions">' TicketOptions "<" *
    | parse EventData with * 'IpAddress">::ffff:' ClientIPAddress "<" *
    | 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 )
    );

Derived from _whitelist_ServiceName, _fromDate, _thruDate, _accounts_to_be_checked.

Let binding: AnomTargetUserNames

let AnomTargetUserNames = (
    baseData
    | extend outliers = series_outliers(dcount_ServiceName)
    | mvexpand dcount_ServiceName, TimeGenerated, outliers to typeof(double)
    | where dcount_ServiceName > _min_dcount_threshold
    | where TimeGenerated >= startofday(now())
    | where outliers > 1.5
    | top 25 by outliers desc
    | summarize make_list(TargetUserName)
    );

Derived from _min_dcount_threshold, baseData.

Stage 1: source

let _accounts_to_be_checked

Stage 2: source

let baseData

Stage 3: source

let AnomTargetUserNames

Stage 4: source

SecurityEvent

Stage 5: where

| where TimeGenerated >= ago(_period)

Stage 6: where

| where EventID == 4769

Stage 7: parse

| parse EventData with * 'Status">' Status "<" *

Stage 8: where

| where Status == '0x0'

Stage 9: parse

| parse EventData with * 'ServiceName">' ServiceName "<" *

Stage 10: where

| where ServiceName !contains "$" and ServiceName !contains "krbtgt"

Stage 11: where

| where ServiceName !in~ (_whitelist_ServiceName )

Stage 12: parse

| parse EventData with * 'TargetUserName">' TargetUserName "<" *

Stage 13: where

| where TargetUserName in (AnomTargetUserNames)

References AnomTargetUserNames (defined above).

Stage 14: parse

| parse EventData with * 'TicketEncryptionType">' TicketEncryptionType "<" *

Stage 15: where

| where TicketEncryptionType in~ ('0x17','0x18')

Stage 16: parse

| parse EventData with * 'TicketOptions">' TicketOptions "<" *

Stage 17: parse

| parse EventData with * 'IpAddress">::ffff:' ClientIPAddress "<" *

Stage 18: summarize

| summarize start_Time=min(TimeGenerated), end_Time=max(TimeGenerated), ServiceNameCount_LastPeriod = dcount(ServiceName), ServiceNameSet_LastPeriod = makeset(ServiceName) by TargetUserName

Stage 19: join

| join kind=leftouter baseData on TargetUserName

Stage 20: where

| where ServiceNameCount_LastPeriod > avg + 2

Stage 21: project

| project start_Time, end_Time, ClientIPAddress, TargetUserName, ServiceNameCount_Avg = avg, ServiceNameCount_LastPeriod, dcount_ServiceName, ServiceNameSet_LastPeriod

Exclusions

Top-level NOT(...) conjuncts: predicates this rule actively suppresses.

FieldKindExcluded values
ServiceNamecontains$
ServiceNamecontainskrbtgt
ServiceNameincontoso.com, contoso.dmz, contoso.local
ServiceNamecontains$
ServiceNamecontainskrbtgt
ServiceNameincontoso.com, contoso.dmz, contoso.local

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
EventIDeq
  • 4769 transforms: cased corpus 10 (splunk 6, kusto 4)
Statuseq
  • 0x0 transforms: cased corpus 3 (kusto 2, sigma 1)
TargetUserNamein
  • AnomTargetUserNames transforms: cased
  • _accounts_to_be_checked
TicketEncryptionTypein
  • 0x17 corpus 8 (splunk 4, sigma 3, kusto 1)
  • 0x18
TimeGeneratedgt
  • _fromDate transforms: cased

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
ClientIPAddressproject
ServiceNameCount_Avgproject
ServiceNameCount_LastPeriodproject
ServiceNameSet_LastPeriodproject
TargetUserNameproject
dcount_ServiceNameproject
end_Timeproject
start_Timeproject