Detection rules › Kusto

Local Admin Group Changes

Status
available
Severity
high
Time window
1h
Group by
AddedAccountSID, NewUserSID, OnPremSid
Source
github.com/Azure/Azure-Sentinel

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

TacticTechniques
PersistenceT1098 Account Manipulation

Event coverage

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 =
ifisnotempty(lUserAdded)strcat(laccountdomain, "\\", lUserAdded)
elsestrcat(DirectoryDomain, "\\", DirectoryAccount)

Stage 9: extend

| extend AccountName = iff(isnotempty(lUserAdded), lUserAdded, DirectoryAccount)
AccountName =
ifisnotempty(lUserAdded)lUserAdded
elseDirectoryAccount

Stage 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 =
ifDeviceName has "."substring(DeviceName, 0, indexof(DeviceName, '.'))
elseDeviceName

Stage 13: extend

| extend DnsDomain = iff(DeviceName has '.', substring(DeviceName, indexof(DeviceName, '.') + 1), "")
DnsDomain =
ifDeviceName has "."substring(DeviceName, (indexof(DeviceName, '.') + 1))
else""

Exclusions

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

FieldKindExcluded values
AccountSidinS-1-5-18, S-1-5-19, S-1-5-20
DeviceNamecontainsActor

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
AccountSidregex_match
  • S-\d-\d+-\d+-(\d+-){1,5}\d+
ActionTypecontains
  • UserAccountCreated
  • UserAccountModified
ActionTypeeq
  • UserAccountAddedToLocalGroup 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
AccountNameproject
ActionTypeproject
Actorproject
AddedAccountSIDproject
DeviceNameproject
LocalGroupproject
LocalGroupSIDproject
TimeGeneratedproject
UserAddedproject
laccountdomainproject
HostNameextend
DnsDomainextend