Detection rules › Kusto
Local Admin Group Changes
This query searches for changes to the local administrators group. Blogpost: https://www.verboon.info/2020/09/hunting-for-local-group-membership-changes.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Persistence | T1098 Account Manipulation |
Event coverage
| Provider | Event/ActionType | Title |
|---|---|---|
| Security-Auditing | Event ID 4720 | A user account was created. |
| Security-Auditing | Event ID 4732 | A member was added to a security-enabled local group. |
| Security-Auditing | Event ID 4738 | A user account was changed. |
| Defender-DeviceEvents | UserAccountAddedToLocalGroup | User account added to local group |
Rule body kusto
id: 63aa43c2-e88e-4102-aea5-0432851c541a
name: Local Admin Group Changes
description: |
This query searches for changes to the local administrators group.
Blogpost: https://www.verboon.info/2020/09/hunting-for-local-group-membership-changes.
severity: High
status: Available
requiredDataConnectors:
- connectorId: MicrosoftThreatProtection
dataTypes:
- IdentityInfo
- DeviceEvents
queryFrequency: 1h
queryPeriod: 1h
triggerOperator: gt
triggerThreshold: 0
tactics:
- Persistence
relevantTechniques:
- T1098
query: |
let machineAccountSIDs = dynamic([
"S-1-5-18",
"S-1-5-20",
"S-1-5-19"]);
let ADAZUsers = IdentityInfo
| extend DirectoryDomain = AccountDomain
| extend DirectoryAccount = AccountName
| extend OnPremSid = AccountSID
| distinct DirectoryDomain , DirectoryAccount , OnPremSid , AccountCloudSID, AccountUPN, GivenName, Surname;
// check for any new created or modified local accounts
let NewUsers = DeviceEvents
| where ActionType contains "UserAccountCreated" or ActionType contains "UserAccountModified"
| extend lUserAdded = AccountName
| extend NewUserSID = AccountSid
| extend laccountdomain = AccountDomain
| distinct NewUserSID, lUserAdded,laccountdomain;
// Check for any local group changes and enrich the data with the account name obtained from the previous query
DeviceEvents
| where ActionType == 'UserAccountAddedToLocalGroup'
// Exclude machine and wellknown SIDs
| where (AccountSid !in (machineAccountSIDs)) and (AccountSid matches regex @"S-\d-\d+-\d+-(\d+-){1,5}\d+")
| extend LocalGroupSID = tostring(parse_json(AdditionalFields).GroupSid)
| extend LocalGroup = tostring(parse_json(AdditionalFields).GroupName)
| extend AddedAccountSID = AccountSid
| extend Actor = trim(@"[^\w]+",InitiatingProcessAccountName)
// limit to local administrators group
// | where LocalGroupSID contains "S-1-5-32-544"
| join kind=leftouter (NewUsers)
on $left.AddedAccountSID == $right.NewUserSID
| project TimeGenerated, DeviceName, LocalGroup,LocalGroupSID, AddedAccountSID, lUserAdded , Actor, ActionType , laccountdomain
| join kind=innerunique (ADAZUsers)
on $left.AddedAccountSID == $right.OnPremSid
| extend UserAdded = iff(isnotempty(lUserAdded),strcat(laccountdomain,"\\", lUserAdded), strcat(DirectoryDomain,"\\", DirectoryAccount))
| extend AccountName = iff(isnotempty(lUserAdded), lUserAdded, DirectoryAccount)
| project TimeGenerated, DeviceName, LocalGroup, LocalGroupSID, AddedAccountSID, UserAdded ,Actor, ActionType, AccountName, laccountdomain
| where DeviceName !contains Actor
// Provide details on actors that added users
// | summarize count() by Actor
// | join ADAZUsers
// on $left.Actor == $right.DirectoryAccount
// | render piechart
| extend HostName = iff(DeviceName has '.', substring(DeviceName, 0, indexof(DeviceName, '.')), DeviceName)
| extend DnsDomain = iff(DeviceName has '.', substring(DeviceName, indexof(DeviceName, '.') + 1), "")
entityMappings:
- entityType: Host
fieldMappings:
- identifier: FullName
columnName: DeviceName
- identifier: HostName
columnName: HostName
- identifier: DnsDomain
columnName: DnsDomain
- entityType: Account
fieldMappings:
- identifier: FullName
columnName: UserAdded
- identifier: Name
columnName: AccountName
- identifier: NTDomain
columnName: laccountdomain
version: 1.0.2
kind: Scheduled
Stages and Predicates
Let binding: machineAccountSIDs
let machineAccountSIDs = dynamic([
"S-1-5-18",
"S-1-5-20",
"S-1-5-19"]);
Let binding: ADAZUsers
let ADAZUsers = IdentityInfo
| extend DirectoryDomain = AccountDomain
| extend DirectoryAccount = AccountName
| extend OnPremSid = AccountSID
| distinct DirectoryDomain , DirectoryAccount , OnPremSid , AccountCloudSID, AccountUPN, GivenName, Surname;
Let binding: NewUsers
let NewUsers = DeviceEvents
| where ActionType contains "UserAccountCreated" or ActionType contains "UserAccountModified"
| extend lUserAdded = AccountName
| extend NewUserSID = AccountSid
| extend laccountdomain = AccountDomain
| distinct NewUserSID, lUserAdded,laccountdomain;
Stage 1: source
DeviceEvents
Stage 2: where
| where ActionType == 'UserAccountAddedToLocalGroup'
Stage 3: where
| where (AccountSid !in (machineAccountSIDs)) and (AccountSid matches regex @"S-\d-\d+-\d+-(\d+-){1,5}\d+")
References machineAccountSIDs (defined above).
Stage 4: extend (4 consecutive steps)
| extend LocalGroupSID = tostring(parse_json(AdditionalFields).GroupSid)
| extend LocalGroup = tostring(parse_json(AdditionalFields).GroupName)
| extend AddedAccountSID = AccountSid
| extend Actor = trim(@"[^\w]+",InitiatingProcessAccountName)
Stage 5: join
| join kind=leftouter (NewUsers)
on $left.AddedAccountSID == $right.NewUserSID
Stage 6: project
| project TimeGenerated, DeviceName, LocalGroup,LocalGroupSID, AddedAccountSID, lUserAdded , Actor, ActionType , laccountdomain
Stage 7: join
| join kind=innerunique (ADAZUsers)
on $left.AddedAccountSID == $right.OnPremSid
Stage 8: extend
| extend UserAdded = iff(isnotempty(lUserAdded),strcat(laccountdomain,"\\", lUserAdded), strcat(DirectoryDomain,"\\", DirectoryAccount))
UserAdded =isnotempty(lUserAdded)strcat(laccountdomain, "\\", lUserAdded)strcat(DirectoryDomain, "\\", DirectoryAccount)Stage 9: extend
| extend AccountName = iff(isnotempty(lUserAdded), lUserAdded, DirectoryAccount)
AccountName =isnotempty(lUserAdded)lUserAddedDirectoryAccountStage 10: project
| project TimeGenerated, DeviceName, LocalGroup, LocalGroupSID, AddedAccountSID, UserAdded ,Actor, ActionType, AccountName, laccountdomain
Stage 11: where
| where DeviceName !contains Actor
Stage 12: extend
| extend HostName = iff(DeviceName has '.', substring(DeviceName, 0, indexof(DeviceName, '.')), DeviceName)
HostName =DeviceName has "."substring(DeviceName, 0, indexof(DeviceName, '.'))DeviceNameStage 13: extend
| extend DnsDomain = iff(DeviceName has '.', substring(DeviceName, indexof(DeviceName, '.') + 1), "")
DnsDomain =DeviceName has "."substring(DeviceName, (indexof(DeviceName, '.') + 1))""Exclusions
Top-level NOT(...) conjuncts: predicates this rule actively suppresses.
| Field | Kind | Excluded values |
|---|---|---|
AccountSid | in | S-1-5-18, S-1-5-19, S-1-5-20 |
DeviceName | contains | Actor |
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 |
|---|---|---|
AccountSid | regex_match |
|
ActionType | contains |
|
ActionType | 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 | project |
ActionType | project |
Actor | project |
AddedAccountSID | project |
DeviceName | project |
LocalGroup | project |
LocalGroupSID | project |
TimeGenerated | project |
UserAdded | project |
laccountdomain | project |
HostName | extend |
DnsDomain | extend |