Detection rules › Kusto
Detect Direct Send phishing emails
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
| Tactic | Techniques |
|---|---|
| Initial Access | T1566 Phishing |
References
- https://www.bleepingcomputer.com/news/security/microsoft-365-direct-send-abused-to-send-phishing-as-internal-users/
- https://www.jumpsec.com/guides/microsoft-direct-send-phishing-abuse-primitive/
- https://techcommunity.microsoft.com/blog/exchange/introducing-more-control-over-direct-send-in-exchange-online/4408790?WT.mc_id=M365-MVP-9501
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.
| Field | Kind | Excluded values |
|---|---|---|
DeliveryAction | in | Blocked, Junked |
UsageCountries | contains | MailFromCountry |
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.
| Field | Kind | Values |
|---|---|---|
Connectors | is_null | |
DMARC | eq |
|
LandCode | is_not_null | |
ResultSignature | eq |
|
SPF | eq |
|
SenderMailFromAddress | eq |
|
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.
| Field | Source |
|---|---|
UserId | summarize |
UserPrincipalName | summarize |