Detection rules › Kusto

Large number of AD objects accessed by user

Group by
Account, DedupEntity
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.

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

References

Telemetry coverage

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

Stage 0: let

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 = ( <inlined as stages below>;
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
);

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

| 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

Stage 12: extend

extend SuspiciousThreshold

Stage 13: where

where ObjectCount > SuspiciousThreshold

Stage 14: project

project Account, ObjectCount, PreviousMaxObjectPerDay, SuspiciousThreshold, TimeGenerated

Stage 15: extend

extend AccountDomain, AccountName

Stage 16: extend

extend DedupFieldValues

Stage 17: kusto:mv-apply

kusto:mv-apply

Stage 18: extend

extend DedupEntity

Stage 19: project-away

project-away DedupFieldValues, DedupValues

Stage 20: join (negated)

join kind=leftanti (SecurityAlert) on DedupEntity

Indicators

These rows show field, operator, and value matches.

FieldKindValuesSearch
EventIDeq
  • 4662 corpus 15 (splunk 7, kusto 4, elastic 3, chronicle 1)
field:"EventID" kind:eq value:"4662"
ObjectCountcross_field_compare
  • SuspiciousThreshold transforms: op:gt
field:"ObjectCount" kind:cross_field_compare value:"SuspiciousThreshold"
ObjectCountgt
  • 20000
field:"ObjectCount" kind:gt value:"20000"
ObjectTypein
  • %{bf967a9c-0de6-11d0-a285-00aa003049e2}
  • %{bf967aba-0de6-11d0-a285-00aa003049e2}
field:"ObjectType" kind:in

Exclusions

The rule actively suppresses these predicates.

Output fields

These fields are emitted when the rule matches.

FieldSource
Accountproject
ObjectCountproject
PreviousMaxObjectPerDayproject
SuspiciousThresholdproject
TimeGeneratedproject
AccountDomainextend
AccountNameextend
DedupEntityextend