← Back to SOC feed Coverage →

External Microsoft Teams Sender Domain Risk

kql MEDIUM Azure-Sentinel
T1566
backdoorhuntingmicrosoftofficial
This rule was pulled from an open-source repository and enriched with AI. Validate in a test environment before deploying to production.
View original rule at Azure-Sentinel →
Retrieved: 2026-09-07T23:00:00Z · Confidence: medium

Hunt Hypothesis

This hypothesis targets adversaries leveraging external Microsoft Teams domains to deliver phishing payloads or malicious links, aligning with MITRE technique T1566, by identifying high-volume senders with low-reputation URLs. Proactively hunting for these patterns in Azure Sentinel allows the SOC to isolate compromised or malicious external conversations before they propagate, reducing the risk of credential theft or malware execution within the organization.

KQL Query

//This query ranks external Microsoft Teams sender domains by threat volume over the last 30 days, split by
//conversation type, with threat-type breakdown, threat rate and a count of messages carrying low-reputation URLs.
//Senders skewing to 1:1 chats are notable because there are no colleagues in the conversation to challenge the request.
//Messages are de-duplicated to the latest record per message; only inbound external threads are counted.
let suspTlds = dynamic(["top","xyz","click","link","shop","online","site","live","icu","cyou","sbs","rest","quest","cfd","bond","buzz","fun","space","monster","work","tk","ml","ga","cf","gq","dev","app","zip","mov","info","win","loan","men","stream","country"]);
let suspUrlMsgs = MessageUrlInfo
    | where Timestamp > ago(30d)
    | where isnotempty(UrlDomain)
    | extend Tld = tostring(split(UrlDomain, ".")[-1])
    | where Tld in (suspTlds)
    | distinct TeamsMessageId;
MessageEvents
| where Timestamp > ago(30d)
| where isnotempty(TeamsMessageId)
//Only the columns used below are carried through the de-duplication, to keep the shuffle cost down.
| summarize arg_max(Timestamp, IsExternalThread, IsOwnedThread, SenderEmailAddress, GroupId, RecipientDetails, ThreatTypes) by TeamsMessageId
| where IsExternalThread == 1 and IsOwnedThread == 0
| extend SenderDomain = tolower(tostring(split(SenderEmailAddress, "@")[1]))
| where isnotempty(SenderDomain)
| extend ConvType = case(isnotempty(GroupId), "Channel",
                         array_length(todynamic(RecipientDetails)) > 1, "Group chat",
                         "1:1 chat")
| extend HasSuspUrl = TeamsMessageId in (suspUrlMsgs)
| summarize TeamsMessages = count(), Senders = dcount(SenderEmailAddress),
            OneToOne = countif(ConvType == "1:1 chat"),
            GroupChat = countif(ConvType == "Group chat"),
            ChannelMsgs = countif(ConvType == "Channel"),
            ThreatMessages = countif(isnotempty(ThreatTypes)),
            Malware = countif(ThreatTypes has "Malware"),
            Phish = countif(ThreatTypes has "Phish"),
            Spam = countif(ThreatTypes has "Spam"),
            LowRepUrlMessages = countif(HasSuspUrl),
            FirstSeen = min(Timestamp), LastSeen = max(Timestamp)
    by SenderDomain
| where ThreatMessages > 0
| extend ThreatRatePct = round(100.0 * ThreatMessages / TeamsMessages, 1)
| top 20 by ThreatMessages desc
| project ['Sender Domain']=SenderDomain, ['Teams Messages']=TeamsMessages, ['Distinct Senders']=Senders,
          ['1:1 Chat']=OneToOne, ['Group Chat']=GroupChat, ['Channel']=ChannelMsgs,
          ['Threat Messages']=ThreatMessages, Malware, Phish, Spam,
          ['Threat Rate %']=ThreatRatePct, ['Low-Rep URL Messages']=LowRepUrlMessages,
          ['First Seen']=FirstSeen, ['Last Seen']=LastSeen

Analytic Rule Definition

id: 91dffff3-b1f9-48bd-b0de-a8940b32e265
name: External Microsoft Teams Sender Domain Risk
description: |
  This query ranks external Microsoft Teams sender domains by threat volume, split by conversation type, with threat rate and low-reputation URL counts.
description-detailed: |
  This query ranks external Microsoft Teams sender domains by the number of messages carrying a threat over the last 30 days, using Advanced hunting in Microsoft Defender XDR. For each domain it returns the split across conversation types (one to one chat, group chat, channel), the breakdown by threat type, the threat rate, a count of messages carrying URLs on low-reputation top level domains, and first and last seen. Conversation shape is the signal that repays attention: legitimate partner domains tend to spread across channels and group chats, whereas social-engineering senders skew heavily toward one to one chats where there are no colleagues present to challenge the request. A domain that is new in the period, skews to one to one, or shows a high threat rate on low volume is the anomaly worth reviewing. Messages are de-duplicated to the latest record per message, and only inbound external threads are counted.
requiredDataConnectors:
- connectorId: MicrosoftThreatProtection
  dataTypes:
  - MessageEvents
  - MessageUrlInfo
tactics:
  - InitialAccess
relevantTechniques:
  - T1566
query: |
  //This query ranks external Microsoft Teams sender domains by threat volume over the last 30 days, split by
  //conversation type, with threat-type breakdown, threat rate and a count of messages carrying low-reputation URLs.
  //Senders skewing to 1:1 chats are notable because there are no colleagues in the conversation to challenge the request.
  //Messages are de-duplicated to the latest record per message; only inbound external threads are counted.
  let suspTlds = dynamic(["top","xyz","click","link","shop","online","site","live","icu","cyou","sbs","rest","quest","cfd","bond","buzz","fun","space","monster","work","tk","ml","ga","cf","gq","dev","app","zip","mov","info","win","loan","men","stream","country"]);
  let suspUrlMsgs = MessageUrlInfo
      | where Timestamp > ago(30d)
      | where isnotempty(UrlDomain)
      | extend Tld = tostring(split(UrlDomain, ".")[-1])
      | where Tld in (suspTlds)
      | distinct TeamsMessageId;
  MessageEvents
  | where Timestamp > ago(30d)
  | where isnotempty(TeamsMessageId)
  //Only the columns used below are carried through the de-duplication, to keep the shuffle cost down.
  | summarize arg_max(Timestamp, IsExternalThread, IsOwnedThread, SenderEmailAddress, GroupId, RecipientDetails, ThreatTypes) by TeamsMessageId
  | where IsExternalThread == 1 and IsOwnedThread == 0
  | extend SenderDomain = tolower(tostring(split(SenderEmailAddress, "@")[1]))
  | where isnotempty(SenderDomain)
  | extend ConvType = case(isnotempty(GroupId), "Channel",
                           array_length(todynamic(RecipientDetails)) > 1, "Group chat",
          

MITRE ATT&CK Context

References

False Positive Guidance

Original source: https://github.com/Azure/Azure-Sentinel/blob/main/Hunting Queries/Microsoft 365 Defender/Email and Collaboration Queries/Microsoft Teams protection/External Microsoft Teams Sender Domain Risk.yaml