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 Access

References

Telemetry coverage

Rule body

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 used in Stage 10

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
);

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 aggregation inside the join branch

summarize by UserId, UserPrincipalName

Exclusions

The rule actively suppresses these predicates.

Indicators

These rows show field, operator, and value matches.

Output fields

These fields are emitted when the rule matches.

FieldSource
UserIdsummarize
UserPrincipalNamesummarize