Detection rules › Kusto
Detect service account login on new device
This detection rule tries to flag suspicious logins on devices from service accounts, for which these service accounts did not login into those devices for the last 14 days. This might indicate that the service account is compromised and is being used for lateral movement into the environment. Most service accounts have a fairly static set of devices they authenticate to. Because of this, it is easier to flag deviations for service accounts compared to user accounts. However, some service accounts are known to dynamically log into devices based on observed events (susch as the MDI service accounts). Because of this some environment specific finetuning might be needed to reduce BP detections.
MITRE ATT&CK coverage
Event coverage
| Provider | Event/ActionType | Title |
|---|---|---|
| Security-Auditing | Event ID 4624 | An account was successfully logged on. |
| Defender-DeviceLogonEvents | LogonSuccess | Logon succeeded |
Rule body yaml
// Get all enabled service accounts
let service_acc = (
IdentityInfo
| where Timestamp > ago(7d)
| where Type == "ServiceAccount" and IsAccountEnabled == 1
| distinct AccountName = tolower(AccountName)
);
// Get the history service account logins
let historic_events = (
DeviceLogonEvents
| where Timestamp between (ago(14d) .. ago(1h))
| where ActionType == "LogonSuccess"
| extend AccountName = tolower(AccountName)
| join kind=inner service_acc on AccountName
| summarize HistoricLogins = make_set(DeviceName) by AccountName
);
// Get the account logins done over Network
DeviceLogonEvents
| where Timestamp > ago(1h)
| where LogonType == "Network"
| where ActionType == "LogonSuccess"
| extend AccountName = tolower(AccountName)
// Join inner to only get known service account logins
| join kind=inner service_acc on AccountName
// Join inner to get a list of the historic device logins for the service accounts
| join kind=inner historic_events on AccountName
// Only get sign-ins where Device is not in the history logins
| extend HistoricLogins = tostring(HistoricLogins)
| where HistoricLogins !contains DeviceName
// Make output better
| project-away AccountName1, AccountName2
// Exclude MDI Service Account - CHANGE IF DIFFERENT FOR YOUR ORG
| where AccountName != "gsma_mdi$"
// Environment specific finetuning - begin
// Environment specific finetuning - end
// Get all enabled service accounts
let service_acc = (
IdentityInfo
| where TimeGenerated > ago(7d)
| where Type == "ServiceAccount" and IsAccountEnabled == 1
| distinct AccountName = tolower(AccountName)
);
// Get the history service account logins
let historic_events = (
DeviceLogonEvents
| where TimeGenerated between (ago(14d) .. ago(1h))
| where ActionType == "LogonSuccess"
| extend AccountName = tolower(AccountName)
| join kind=inner service_acc on AccountName
| summarize HistoricLogins = make_set(DeviceName) by AccountName
);
// Get the account logins done over Network
DeviceLogonEvents
| where TimeGenerated > ago(1h)
| where LogonType == "Network"
| where ActionType == "LogonSuccess"
| extend AccountName = tolower(AccountName)
// Join inner to only get known service account logins
| join kind=inner service_acc on AccountName
// Join inner to get a list of the historic device logins for the service accounts
| join kind=inner historic_events on AccountName
// Only get sign-ins where Device is not in the history logins
| extend HistoricLogins = tostring(HistoricLogins)
| where HistoricLogins !contains DeviceName
// Make output better
| project-away AccountName1, AccountName2
// Exclude MDI Service Account - CHANGE IF DIFFERENT FOR YOUR ORG
| where AccountName != "gsma_mdi$"
// Environment specific finetuning - begin
// Environment specific finetuning - end
Stages and Predicates
Let binding: service_acc
let service_acc = (
IdentityInfo
| where Timestamp > ago(7d)
| where Type == "ServiceAccount" and IsAccountEnabled == 1
| distinct AccountName = tolower(AccountName)
);
Let binding: historic_events
let historic_events = (
DeviceLogonEvents
| where Timestamp between (ago(14d) .. ago(1h))
| where ActionType == "LogonSuccess"
| extend AccountName = tolower(AccountName)
| join kind=inner service_acc on AccountName
| summarize HistoricLogins = make_set(DeviceName) by AccountName
);
Derived from service_acc.
Stage 1: source
DeviceLogonEvents
Stage 2: where
| where Timestamp > ago(1h)
Stage 3: where
| where LogonType == "Network"
Stage 4: where
| where ActionType == "LogonSuccess"
Stage 5: extend
| extend AccountName = tolower(AccountName)
Stage 6: join
| join kind=inner service_acc on AccountName
Stage 7: join
| join kind=inner historic_events on AccountName
Stage 8: extend
| extend HistoricLogins = tostring(HistoricLogins)
Stage 9: where
| where HistoricLogins !contains DeviceName
Stage 10: project-away
| project-away AccountName1, AccountName2
Stage 11: where
| where AccountName != "gsma_mdi$"
Stage 12: summarize
summarize by AccountName
Exclusions
Top-level NOT(...) conjuncts: predicates this rule actively suppresses.
| Field | Kind | Excluded values |
|---|---|---|
HistoricLogins | contains | DeviceName |
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 |
|---|---|---|
AccountName | ne |
|
ActionType | eq |
|
IsAccountEnabled | eq |
|
LogonType | eq |
|
Type | eq |
|
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 |
|---|---|
AccountName | summarize |