Detection rules › Kusto

Exchange workflow MailItemsAccessed operation anomaly

Status
available
Severity
medium
Time window
14d
Source
github.com/Azure/Azure-Sentinel

Identifies anomalous increases in Exchange mail items accessed operations. The query leverages KQL built-in anomaly detection algorithms to find large deviations from baseline patterns. Sudden increases in execution frequency of sensitive actions should be further investigated for malicious activity. Manually change scorethreshold from 1.5 to 3 or higher to reduce the noise based on outliers flagged from the query criteria. Read more about MailItemsAccessed- https://learn.microsoft.com/en-us/purview/audit-log-investigate-accounts

MITRE ATT&CK coverage

TacticTechniques
CollectionT1114 Email Collection

Event coverage

Rules detecting the same action

Other rules on this platform that filter on the same API call or operation.

Rule body kusto

id: b4ceb583-4c44-4555-8ecf-39f572e827ba
name: Exchange workflow MailItemsAccessed operation anomaly
description: |
  'Identifies anomalous increases in Exchange mail items accessed operations.
  The query leverages KQL built-in anomaly detection algorithms to find large deviations from baseline patterns.
  Sudden increases in execution frequency of sensitive actions should be further investigated for malicious activity.
  Manually change scorethreshold from 1.5 to 3 or higher to reduce the noise based on outliers flagged from the query criteria.
  Read more about MailItemsAccessed- https://learn.microsoft.com/en-us/purview/audit-log-investigate-accounts'
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: Office365
    dataTypes:
      - OfficeActivity (Exchange)
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Collection
relevantTechniques:
  - T1114
tags:
  - Solorigate
  - NOBELIUM
query: |
  let starttime = 14d;
  let endtime = 1d;
  let timeframe = 1h;
  let scorethreshold = 1.5;
  let percentthreshold = 50;
  // Preparing the time series data aggregated hourly count of MailItemsAccessd Operation in the form of multi-value array to use with time series anomaly function.
  let TimeSeriesData =
  OfficeActivity
  | where TimeGenerated  between (startofday(ago(starttime))..startofday(ago(endtime)))
  | where OfficeWorkload=~ "Exchange" and Operation =~ "MailItemsAccessed" and ResultStatus =~ "Succeeded"
  | project TimeGenerated, Operation, MailboxOwnerUPN
  | make-series Total=count() on TimeGenerated from startofday(ago(starttime)) to startofday(ago(endtime)) step timeframe;
  let TimeSeriesAlerts = TimeSeriesData
  | extend (anomalies, score, baseline) = series_decompose_anomalies(Total, scorethreshold, -1, 'linefit')
  | mv-expand Total to typeof(double), TimeGenerated to typeof(datetime), anomalies to typeof(double), score to typeof(double), baseline to typeof(long)
  | where anomalies > 0
  | project TimeGenerated, Total, baseline, anomalies, score;
  // Joining the flagged outlier from the previous step with the original dataset to present contextual information
  // during the anomalyhour to analysts to conduct investigation or informed decisions.
  TimeSeriesAlerts | where TimeGenerated > ago(2d)
  // Join against base logs since specified timeframe to retrive records associated with the hour of anomoly
  | join kind=innerunique (
      OfficeActivity
      | where TimeGenerated > ago(2d)
      | extend DateHour = bin(TimeGenerated, 1h)
      | where OfficeWorkload=~ "Exchange" and Operation =~ "MailItemsAccessed" and ResultStatus =~ "Succeeded"
      | summarize HourlyCount=count(), TimeGeneratedMax = arg_max(TimeGenerated, *), IPAdressList = make_set(Client_IPAddress, 1000), SourceIPMax= arg_max(Client_IPAddress, *), ClientInfoStringList= make_set(ClientInfoString, 1000) by MailboxOwnerUPN, Logon_Type, TenantId, UserType, TimeGenerated = bin(TimeGenerated, 1h)
      | where HourlyCount > 25 // Only considering operations with more than 25 hourly count to reduce False Positivies
      | order by HourlyCount desc
  ) on TimeGenerated
  | extend PercentofTotal = round(HourlyCount/Total, 2) * 100
  | where PercentofTotal > percentthreshold // Filter Users with count of less than 5 percent of TotalEvents per Hour to remove FPs/ users with very low count of MailItemsAccessed events
  | order by PercentofTotal desc
  | project-reorder TimeGeneratedMax, Type, OfficeWorkload, Operation, UserId, SourceIPMax, IPAdressList, ClientInfoStringList, HourlyCount, PercentofTotal, Total, baseline, score, anomalies
  | extend AccountName = tostring(split(UserId, "@")[0]), AccountUPNSuffix = tostring(split(UserId, "@")[1])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: UserId
      - identifier: Name
        columnName: AccountName
      - identifier: UPNSuffix
        columnName: AccountUPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: Client_IPAddress
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: SourceIPMax
version: 2.0.6
kind: Scheduled

Stages and Predicates

Parameters

let starttime = 14d;
let endtime = 1d;
let timeframe = 1h;
let scorethreshold = 1.5;
let percentthreshold = 50;

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

Stage 1: source

OfficeActivity

Stage 2: where

| where TimeGenerated  between (startofday(ago(starttime))..startofday(ago(endtime)))

Stage 3: where

| where OfficeWorkload=~ "Exchange" and Operation =~ "MailItemsAccessed" and ResultStatus =~ "Succeeded"

Stage 4: project

| project TimeGenerated, Operation, MailboxOwnerUPN

The stages below score time-series anomalies (make-series, series_decompose_anomalies).

Stage 5: summarize

| make-series Total=count() on TimeGenerated from startofday(ago(starttime)) to startofday(ago(endtime)) step timeframe

Stage 6: extend

| extend (anomalies, score, baseline) = series_decompose_anomalies(Total, scorethreshold, -1, 'linefit')

Stage 7: mv-expand

| mv-expand Total to typeof(double), TimeGenerated to typeof(datetime), anomalies to typeof(double), score to typeof(double), baseline to typeof(long)

Stage 8: where

| where anomalies > 0

Stage 9: project

| project TimeGenerated, Total, baseline, anomalies, score

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

Stage 10: where

TimeSeriesAlerts
| where TimeGenerated > ago(2d)

Stage 11: join

| join kind=innerunique (
    OfficeActivity
    | where TimeGenerated > ago(2d)
    | extend DateHour = bin(TimeGenerated, 1h)
    | where OfficeWorkload=~ "Exchange" and Operation =~ "MailItemsAccessed" and ResultStatus =~ "Succeeded"
    | summarize HourlyCount=count(), TimeGeneratedMax = arg_max(TimeGenerated, *), IPAdressList = make_set(Client_IPAddress, 1000), SourceIPMax= arg_max(Client_IPAddress, *), ClientInfoStringList= make_set(ClientInfoString, 1000) by MailboxOwnerUPN, Logon_Type, TenantId, UserType, TimeGenerated = bin(TimeGenerated, 1h)
    | where HourlyCount > 25
    | order by HourlyCount desc
) on TimeGenerated

Stage 12: extend

| extend PercentofTotal = round(HourlyCount/Total, 2) * 100

Stage 13: where

| where PercentofTotal > percentthreshold

Stage 14: sort

| order by PercentofTotal desc

Stage 15: project-reorder

| project-reorder TimeGeneratedMax, Type, OfficeWorkload, Operation, UserId, SourceIPMax, IPAdressList, ClientInfoStringList, HourlyCount, PercentofTotal, Total, baseline, score, anomalies

Stage 16: extend

| extend AccountName = tostring(split(UserId, "@")[0]), AccountUPNSuffix = tostring(split(UserId, "@")[1])

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
HourlyCountgt
  • 25 transforms: cased
OfficeWorkloadeq
  • Exchange
Operationeq
  • MailItemsAccessed
PercentofTotalgt
  • 50 transforms: cased
ResultStatuseq
  • Succeeded
anomaliesgt
  • 0 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
TimeGeneratedproject
Totalproject
anomaliesproject
baselineproject
scoreproject
PercentofTotalextend
AccountNameextend
AccountUPNSuffixextend