Detection rules › Kusto
T1558.003 - Kerberoasting
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
| Tactic | Techniques |
|---|---|
| Credential Access | T1558.003 Steal or Forge Kerberos Tickets: Kerberoasting |
References
Event coverage
| Provider | Event | Title |
|---|---|---|
| Security-Auditing | Event ID 4769 | A Kerberos service ticket was requested. |
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.
| Field | Kind | Excluded values |
|---|---|---|
ServiceName | contains | $ |
ServiceName | contains | krbtgt |
ServiceName | in | contoso.com, contoso.dmz, contoso.local |
ServiceName | contains | $ |
ServiceName | contains | krbtgt |
ServiceName | in | contoso.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.
| Field | Kind | Values |
|---|---|---|
EventID | eq |
|
Status | eq |
|
TargetUserName | in |
|
TicketEncryptionType | in |
|
TimeGenerated | gt |
|
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.
| Field | Source |
|---|---|
ClientIPAddress | project |
ServiceNameCount_Avg | project |
ServiceNameCount_LastPeriod | project |
ServiceNameSet_LastPeriod | project |
TargetUserName | project |
dcount_ServiceName | project |
end_Time | project |
start_Time | project |