Detection rules › Kusto
Large number of AD objects accessed by user
This query detects a user accessing a large number of Group and User objects from Active Directory which is outside the baseline of normal behavior for that particular user.
Known false positives
- There are legitimate use-cases for downloading large number of AD objects. For example, an administrator might run a script to extract all users as part of certain migration activities.
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Discovery | |
| Collection |
References
Telemetry coverage
| Provider | Record / event type |
|---|---|
| Security-Auditing | Event ID 4662: An operation was performed on an object. |
Rule body
let timeframe = 2*1d;
let RuleId = "0108";
let DedupFields = dynamic(["TimeGenerated"]);
let lookback_days=14d; // Look back this many days to calculate baseline for maximum number of objects accessed per day.
let min_suspicious_count = 20000; // Only consider users looking up at least this many objects during the timeframe.
let suspicious_factor = 50; // Consider as suspicious if requesting more than suspicious_factor*daily maximum in lookback period.
let ADObjectAccess=(
SecurityEvent
| where EventID == 4662
| where not(Account endswith "$")
| where ObjectType in~ (
"%{bf967aba-0de6-11d0-a285-00aa003049e2}", // User.
"%{bf967a9c-0de6-11d0-a285-00aa003049e2}" // Group.
)
);
let AccessBaseline=(
ADObjectAccess
| where TimeGenerated <= ago(timeframe)
| where TimeGenerated >= ago(timeframe + lookback_days)
| summarize BaselineObjectCount=count() by Account, bin(TimeGenerated,1d)
| summarize PreviousMaxObjectPerDay=max(BaselineObjectCount) by Account
);
ADObjectAccess
| where ingestion_time() >= ago(timeframe)
| summarize ObjectCount=count(),TimeGenerated=min(TimeGenerated) by Account
| where ObjectCount > min_suspicious_count
| join kind=leftouter AccessBaseline on Account
| extend PreviousMaxObjectPerDay=coalesce(PreviousMaxObjectPerDay,0)
// Calculate what is considered a suspicious number for the given user.
| extend SuspiciousThreshold=max_of(PreviousMaxObjectPerDay*suspicious_factor,min_suspicious_count)
| where ObjectCount > SuspiciousThreshold
| project TimeGenerated, Account, ObjectCount, PreviousMaxObjectPerDay, SuspiciousThreshold
| extend AccountName=iif(Account contains @"\",tostring(split(Account,@"\")[1]),Account),AccountDomain=iif(Account contains @"\",tostring(split(Account,@"\")[0]),"")
// Begin environment-specific filter.
// End environment-specific filter.
// Begin de-duplication logic.
| extend DedupFieldValues=pack_all()
| mv-apply e=DedupFields to typeof(string) on (
extend DedupValue=DedupFieldValues[tostring(e)]
| order by e // Sorting is required to ensure make_list is deterministic.
| summarize DedupValues=make_list(DedupValue)
)
| extend DedupEntity=strcat_array(DedupValues, "|")
| project-away DedupFieldValues, DedupValues
| join kind=leftanti (
SecurityAlert
| where AlertName has RuleId and ProviderName has "ASI"
| where TimeGenerated >= ago(timeframe)
| extend DedupEntity = tostring(parse_json(tostring(parse_json(ExtendedProperties)["Custom Details"])).DedupEntity[0])
| project DedupEntity
) on DedupEntity
// End de-duplication logic.
Stages and Predicates
Parameters
let timeframe = 2*1d;
let RuleId = "0108";
let DedupFields = dynamic(["TimeGenerated"]);
let lookback_days = 14d;
let min_suspicious_count = 20000;
let suspicious_factor = 50;
let ADObjectAccess is inlined into the numbered stages below.
Let binding: AccessBaseline
let AccessBaseline = (
ADObjectAccess
| where TimeGenerated <= ago(timeframe)
| where TimeGenerated >= ago(timeframe + lookback_days)
| summarize BaselineObjectCount=count() by Account, bin(TimeGenerated,1d)
| summarize PreviousMaxObjectPerDay=max(BaselineObjectCount) by Account
);
Stages 1 to 6 define let ADObjectAccess (the rule's main pipeline source); stages 7 to 20 run on it.
Stage 1: source
let ADObjectAccess
Stage 2: source
let AccessBaseline
Stage 3: source
SecurityEvent
Stage 4: where
| where EventID == 4662
Stage 5: where
| where not(Account endswith "$")
Stage 6: where
| where ObjectType in~ (
"%{bf967aba-0de6-11d0-a285-00aa003049e2}",
"%{bf967a9c-0de6-11d0-a285-00aa003049e2}"
)
Stage 7: where
ADObjectAccess
| where ingestion_time() >= ago(timeframe)
Stage 8: summarize
| summarize ObjectCount=count(),TimeGenerated=min(TimeGenerated) by Account
Stage 9: where
| where ObjectCount > min_suspicious_count
Stage 10: join
| join kind=leftouter AccessBaseline on Account
Stage 11: extend
| extend PreviousMaxObjectPerDay=coalesce(PreviousMaxObjectPerDay,0)
Stage 12: extend
| extend SuspiciousThreshold=max_of(PreviousMaxObjectPerDay*suspicious_factor,min_suspicious_count)
Stage 13: where
| where ObjectCount > SuspiciousThreshold
Stage 14: project
| project TimeGenerated, Account, ObjectCount, PreviousMaxObjectPerDay, SuspiciousThreshold
Stage 15: extend
| extend AccountName=iif(Account contains @"\",tostring(split(Account,@"\")[1]),Account),AccountDomain=iif(Account contains @"\",tostring(split(Account,@"\")[0]),"")
Stage 16: extend
| extend DedupFieldValues=pack_all()
Stage 17: kusto:mv-apply
| mv-apply e=DedupFields to typeof(string) on (
extend DedupValue=DedupFieldValues[tostring(e)]
| order by e
| summarize DedupValues=make_list(DedupValue)
)
Stage 18: extend
| extend DedupEntity=strcat_array(DedupValues, "|")
Stage 19: project-away
| project-away DedupFieldValues, DedupValues
Stage 20: join (negated)
| join kind=leftanti (
SecurityAlert
| where AlertName has RuleId and ProviderName has "ASI"
| where TimeGenerated >= ago(timeframe)
| extend DedupEntity = tostring(parse_json(tostring(parse_json(ExtendedProperties)["Custom Details"])).DedupEntity[0])
| project DedupEntity
) on DedupEntity
Exclusions
The rule actively suppresses these predicates.
| Field | Kind | Excluded values | Search |
|---|---|---|---|
Account | ends_with | $ | excludes:Account field:"Account" value:"$" |
AlertName | match | 0108 | excludes:AlertName field:"AlertName" value:"0108" |
ProviderName | match | ASI | excludes:ProviderName field:"ProviderName" value:"ASI" |
Indicators
These rows show field, operator, and value matches.
| Field | Kind | Values | Search |
|---|---|---|---|
EventID | eq |
| field:"EventID" kind:eq value:"4662" |
ObjectCount | cross_field_compare |
| field:"ObjectCount" kind:cross_field_compare value:"SuspiciousThreshold" |
ObjectCount | gt |
| field:"ObjectCount" kind:gt value:"20000" |
ObjectType | in |
| field:"ObjectType" kind:in |
Output fields
These fields are emitted when the rule matches.
| Field | Source |
|---|---|
Account | project |
ObjectCount | project |
PreviousMaxObjectPerDay | project |
SuspiciousThreshold | project |
TimeGenerated | project |
AccountDomain | extend |
AccountName | extend |
DedupEntity | extend |