Detection rules › Kusto

Detect Direct Send phishing emails

Group by
SenderObjectId, UserId, UserPrincipalName
Author
Robbe Van den Daele
Source
github.com/HybridBrothers/Hunting-Queries-Detection-Rules

In May 2025, a campaign started using the Microsoft Exchange Direct Send feature to send phishing mails to organizations. This feature is designed for use by printers, scanners, cloud services and other devices that need to send messages on behalf of the company. This detection rule tries to detect the malicious mails being send via Direct Send using a couple of indicators: - Sender email is the same as Recipient email - SPF and DMARC both failed - Mail is not comming in via an Exchange connector - The sender IP address country is not a country that the user is known to login from FYI, if you remove the country check (last line of the query), you can use the query to identify if there are legitimate mails as well. If not, you can disable the Direct Send feature in Exchange Online.

MITRE ATT&CK coverage

TacticTechniques
Initial AccessT1566 Phishing

References

Rule body yaml

let country_land_codes = (
    externaldata (Country:string,Alpha2:string,Alpha3:string,CountryCode:string,Iso3166:string,Region:string,SubRegion:string,IntermediateRegion:string,RegionCode:string,SubRegionCode:string,IntermediateRegionCode:string) 
    ['https://raw.githubusercontent.com/lukes/ISO-3166-Countries-with-Regional-Codes/refs/heads/master/all/all.csv'] with (format='csv', ignoreFirstRecord=true)
    | distinct Country, Alpha2
);
let login_locations = (
    SigninLogs
    | where TimeGenerated > ago(7d)
    | where ResultSignature == 0
    | distinct UserId, UserPrincipalName, LandCode = tostring(LocationDetails.countryOrRegion)
    | where isnotempty(LandCode)
    | join kind=inner country_land_codes on $left.LandCode == $right.Alpha2
    | summarize UsageCountries = make_set(Country) by UserId, UserPrincipalName
);
EmailEvents
| where TimeGenerated > ago(30d)
// Find Direct Send mails
| where SenderMailFromAddress == RecipientEmailAddress
| extend AuthenticationDetails = parse_json(AuthenticationDetails)
| where AuthenticationDetails.SPF == "fail" and AuthenticationDetails.DMARC == "fail"
// Only flag mails not comming in via an exchange connector (these are legit mails)
| where isempty(Connectors)
// Exclude if detected as phish (since it is already remediated then)
| where DeliveryAction !in ("Junked", "Blocked")
| extend Location = parse_json(geo_info_from_ip_address(SenderIPv4))
| extend MailFromCountry = tostring(Location.country)
// Find the usage countries of the users
| join kind=leftouter login_locations on $left.SenderObjectId == $right.UserId
// Flag when sender country is not a usage country of the user
| where tostring(UsageCountries) !contains MailFromCountry

Stages and Predicates

Let binding: country_land_codes

let country_land_codes = (
    externaldata (Country:string,Alpha2:string,Alpha3:string,CountryCode:string,Iso3166:string,Region:string,SubRegion:string,IntermediateRegion:string,RegionCode:string,SubRegionCode:string,IntermediateRegionCode:string) 
    ['https://raw.githubusercontent.com/lukes/ISO-3166-Countries-with-Regional-Codes/refs/heads/master/all/all.csv'] with (format='csv', ignoreFirstRecord=true)
    | distinct Country, Alpha2
);

Let binding: login_locations

let login_locations = (
    SigninLogs
    | where TimeGenerated > ago(7d)
    | where ResultSignature == 0
    | distinct UserId, UserPrincipalName, LandCode = tostring(LocationDetails.countryOrRegion)
    | where isnotempty(LandCode)
    | join kind=inner country_land_codes on $left.LandCode == $right.Alpha2
    | summarize UsageCountries = make_set(Country) by UserId, UserPrincipalName
);

Derived from country_land_codes.

Stage 1: source

EmailEvents

Stage 2: where

| where TimeGenerated > ago(30d)

Stage 3: where

| where SenderMailFromAddress == RecipientEmailAddress

Stage 4: extend

| extend AuthenticationDetails = parse_json(AuthenticationDetails)

Stage 5: where

| where AuthenticationDetails.SPF == "fail" and AuthenticationDetails.DMARC == "fail"

Stage 6: where

| where isempty(Connectors)

Stage 7: where

| where DeliveryAction !in ("Junked", "Blocked")

Stage 8: extend

| extend Location = parse_json(geo_info_from_ip_address(SenderIPv4))

Stage 9: extend

| extend MailFromCountry = tostring(Location.country)

Stage 10: join

| join kind=leftouter login_locations on $left.SenderObjectId == $right.UserId

Stage 11: where

| where tostring(UsageCountries) !contains MailFromCountry

Stage 12: summarize

summarize by UserId, UserPrincipalName

Exclusions

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

FieldKindExcluded values
DeliveryActioninBlocked, Junked
UsageCountriescontainsMailFromCountry

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
Connectorsis_null
  • (no value, null check)
DMARCeq
  • fail transforms: cased
LandCodeis_not_null
  • (no value, null check)
ResultSignatureeq
  • 0 transforms: cased
SPFeq
  • fail transforms: cased
SenderMailFromAddresseq
  • RecipientEmailAddress 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
UserIdsummarize
UserPrincipalNamesummarize