Detection rules › Kusto

NRT Multiple users email forwarded to same destination

Severity
medium
Group by
ClientIP
Author
Pete Bryan
Source
github.com/Azure/Azure-Sentinel

'Identifies when multiple (more than one) users mailboxes are configured to forward to the same destination. This could be an attacker-controlled destination mailbox configured to collect mail from multiple compromised user accounts.'

MITRE ATT&CK coverage

TacticTechniques
CollectionT1114 Email Collection
ExfiltrationT1020 Automated Exfiltration

Rules detecting the same action

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

Rule body kusto

id: 3b05727d-a8d1-477d-bbdd-d957da96ac7b
name: NRT Multiple users email forwarded to same destination
description: |
  'Identifies when multiple (more than one) users mailboxes are configured to forward to the same destination.
  This could be an attacker-controlled destination mailbox configured to collect mail from multiple compromised user accounts.'
severity: Medium
requiredDataConnectors:
  - connectorId: Office365
    dataTypes:
      - OfficeActivity
tactics:
  - Collection
  - Exfiltration
relevantTechniques:
  - T1114
  - T1020
query: |
  OfficeActivity
  | where OfficeWorkload =~ "Exchange"
  | where Parameters has_any ("ForwardTo", "RedirectTo", "ForwardingSmtpAddress")
  | mv-apply DynamicParameters = todynamic(Parameters) on (summarize ParsedParameters = make_bag(pack(tostring(DynamicParameters.Name), DynamicParameters.Value)))
  | evaluate bag_unpack(ParsedParameters, columnsConflict='replace_source')
  | extend DestinationMailAddress = tolower(case(
      isnotempty(column_ifexists("ForwardTo", "")), column_ifexists("ForwardTo", ""),
      isnotempty(column_ifexists("RedirectTo", "")), column_ifexists("RedirectTo", ""),
      isnotempty(column_ifexists("ForwardingSmtpAddress", "")), trim_start(@"smtp:", column_ifexists("ForwardingSmtpAddress", "")),
      ""))
  | where isnotempty(DestinationMailAddress)
  | mv-expand split(DestinationMailAddress, ";")
  | extend ClientIPValues = extract_all(@'\[?(::ffff:)?(?P<IPAddress>(\d+\.\d+\.\d+\.\d+)|[^\]]+)\]?([-:](?P<Port>\d+))?', dynamic(["IPAddress", "Port"]), ClientIP)[0]
  | extend ClientIP = tostring(ClientIPValues[0]), Port = tostring(ClientIPValues[1])
  | summarize StartTime = min(TimeGenerated), EndTime = max(TimeGenerated), DistinctUserCount = dcount(UserId), UserId = make_set(UserId, 250), Ports = make_set(Port, 250), EventCount = count() by tostring(DestinationMailAddress), ClientIP
  | where DistinctUserCount > 1
  | mv-expand UserId to typeof(string)
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: UserId
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: ClientIP
version: 1.0.3
kind: NRT
metadata:
    source:
        kind: Community
    author:
        name: Pete Bryan
    support:
        tier: Community
    categories:
        domains: [ "Security - Threat Protection" ]

Stages and Predicates

Stage 1: source

OfficeActivity

Stage 2: where

| where OfficeWorkload =~ "Exchange"

Stage 3: where

| where Parameters has_any ("ForwardTo", "RedirectTo", "ForwardingSmtpAddress")

Stage 4: kusto:mv-apply

| mv-apply DynamicParameters = todynamic(Parameters) on (summarize ParsedParameters = make_bag(pack(tostring(DynamicParameters.Name), DynamicParameters.Value)))

Stage 5: evaluate

| evaluate bag_unpack(ParsedParameters, columnsConflict='replace_source')

Stage 6: extend

| extend DestinationMailAddress = tolower(case(
    isnotempty(column_ifexists("ForwardTo", "")), column_ifexists("ForwardTo", ""),
    isnotempty(column_ifexists("RedirectTo", "")), column_ifexists("RedirectTo", ""),
    isnotempty(column_ifexists("ForwardingSmtpAddress", "")), trim_start(@"smtp:", column_ifexists("ForwardingSmtpAddress", "")),
    ""))

Stage 7: where

| where isnotempty(DestinationMailAddress)

Stage 8: mv-expand

| mv-expand split(DestinationMailAddress, ";")

Stage 9: extend

| extend ClientIPValues = extract_all(@'\[?(::ffff:)?(?P<IPAddress>(\d+\.\d+\.\d+\.\d+)|[^\]]+)\]?([-:](?P<Port>\d+))?', dynamic(["IPAddress", "Port"]), ClientIP)[0]

Stage 10: extend

| extend ClientIP = tostring(ClientIPValues[0]), Port = tostring(ClientIPValues[1])

Stage 11: summarize

| summarize StartTime = min(TimeGenerated), EndTime = max(TimeGenerated), DistinctUserCount = dcount(UserId), UserId = make_set(UserId, 250), Ports = make_set(Port, 250), EventCount = count() by tostring(DestinationMailAddress), ClientIP
Threshold
gt 1

Stage 12: where

| where DistinctUserCount > 1

Stage 13: mv-expand

| mv-expand UserId to typeof(string)

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
DestinationMailAddressis_not_null
  • (no value, null check)
DistinctUserCountgt
  • 1 transforms: cased
OfficeWorkloadeq
  • Exchange
Parametersmatch
  • ForwardTo
  • ForwardingSmtpAddress
  • RedirectTo

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
ClientIPsummarize
DistinctUserCountsummarize
EndTimesummarize
EventCountsummarize
Portssummarize
StartTimesummarize
UserIdsummarize