Detection rules › Kusto

Large number of AD objects accessed by user

Group by
Account
Author
FalconForce
Source
github.com/FalconForceTeam/FalconFriday

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.

MITRE ATT&CK coverage

References

Event coverage

Rule body kusto

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 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
);

Derived from timeframe, lookback_days, ADObjectAccess.

The stages below define let ADObjectAccess (the rule's main pipeline source).

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}"
    )

The stages below run on ADObjectAccess (the outer pipeline).

Stage 7: where

ADObjectAccess
| where ingestion_time() >= ago(timeframe)

Stage 8: summarize

| summarize ObjectCount=count(),TimeGenerated=min(TimeGenerated) by Account
Threshold
gt 20000

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

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

FieldKindExcluded values
Accountends_with$
Accountends_with$
AlertNamematch0108
ProviderNamematchASI

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
EventIDeq
  • 4662 transforms: cased corpus 13 (splunk 7, kusto 4, elastic 1, chronicle 1)
ObjectCountgt
  • 20000 transforms: cased
  • SuspiciousThreshold transforms: cased
ObjectTypein
  • %{bf967a9c-0de6-11d0-a285-00aa003049e2}
  • %{bf967aba-0de6-11d0-a285-00aa003049e2}

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
Accountproject
ObjectCountproject
PreviousMaxObjectPerDayproject
SuspiciousThresholdproject
TimeGeneratedproject
AccountDomainextend
AccountNameextend
DedupEntityextend