M365 Threat Hunting Cheatsheet
Hunting starts from a hypothesis, not an alert. The hunt method, the KQL operators that detection rules do not use, and real hunts by objective for Microsoft 365, Sentinel, and Defender XDR. No account needed.
Threat hunting is proactive: you assume a compromise the alerts missed and go looking for it. That changes the method, you begin with a testable hypothesis and a defined scope, not with something that already fired. The queries here are written for that, against Sentinel (Logs) and Defender XDR (Advanced Hunting). Set your own thresholds and org values before running.
The hunt method
A hunt is only as good as its hypothesis. A testable hypothesis names the adversary behavior, the data that would show it, and what would count as a hit, so you know when the hunt is done and what the result means. Vague hunts ("look for anything weird in sign-ins") never resolve; specific ones do.
| Scope dimension | Decide before you query |
|---|---|
| Data source | Which table(s) would contain the behavior if it happened. Wrong table = false confidence in a clean result. |
| Time window | How far back. Long enough to catch the behavior, short enough to stay inside Advanced Hunting limits. |
| Population | All users, or a segment (privileged, external, a business unit). Narrowing cuts noise. |
| Success criteria | What a hit looks like, and what a clean result proves. Define this first or the hunt never closes. |
Hunt KQL you do not use in detections
Detection rules match known-bad. Hunting finds the unknown, which needs operators that describe normal and surface deviation. Behavioral baselining with make-series and anomaly decomposition is the core hunting technique: build a per-entity time series, then let the math flag the entity that broke its own pattern.
// Per-user sign-in volume as a daily time series over 30 days
SigninLogs
| where TimeGenerated > ago(30d)
| where ResultType == "0"
| make-series SignInCount = count()
on TimeGenerated
from ago(30d) to now()
step 1d
by UserPrincipalName
// Each row = one user
// SignInCount = array of 30 values, one per day
// TimeGenerated = array of 30 timestamps
// Includes days with zero sign-ins
// This format is the input for series_decompose_anomalies()
From a baseline like this, series_decompose_anomalies() scores each point against the entity's own seasonal pattern, surfacing the user whose sign-in volume, or failure rate, or download count spiked relative to their normal, not relative to a fixed threshold. That per-entity baseline is what separates a hunt from a static rule.
| Operator / technique | What it does for a hunt |
|---|---|
| make-series | Builds a per-entity time series (the baseline of normal behavior). |
| series_decompose_anomalies | Flags points that deviate from the entity's own seasonal pattern. |
| join kind=leftanti | Find this-window activity absent from a baseline window (new IPs, new apps). |
| arg_max / make_set | Profile an entity: its last/typical values, its set of source IPs. |
| Multi-hop join | Chain tables to follow an attack across identity, endpoint, and cloud. |
Hunts by objective
High-value hunts, each a real query with its hypothesis and thresholds. Tune the counts and windows to your environment; the logic is the transferable part.
Password spray (identity)
// Password Spray Hunt — many accounts, same time window, same error
let sprayWindow = 1h;
let minTargets = 20; // At least 20 accounts targeted
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == "50126" // Invalid username or password
| summarize
TargetedAccounts = dcount(UserPrincipalName),
TotalAttempts = count(),
SourceIPs = make_set(IPAddress, 50),
IPCount = dcount(IPAddress),
Countries = make_set(
tostring(LocationDetails.countryOrRegion), 10),
UserAgents = make_set(UserAgent, 10),
Apps = make_set(AppDisplayName, 10),
AttemptsPerAccount = round(
todouble(count()) / todouble(dcount(UserPrincipalName)), 2)
by bin(TimeGenerated, sprayWindow)
| where TargetedAccounts >= minTargets
// A burst of 20+ accounts failing in the same 1-hour window
// is the textbook spray signature
| extend SprayConfidence = case(
AttemptsPerAccount "HIGH — classic spray (few attempts/account, few IPs)",
AttemptsPerAccount 5,
"HIGH — distributed spray (proxied)",
AttemptsPerAccount > 5,
"MEDIUM — may be spray + brute force hybrid",
"LOW")
| sort by TargetedAccounts desc
// AttemptsPerAccount near 1 = pure spray (one password, all accounts)
// AttemptsPerAccount 2-3 = multi-password spray (2-3 passwords tested)
// UserAgents set: if all failures share one UserAgent, automated tool
// Apps set: should be "Microsoft Online" or "Office 365" — the
// Entra ID login endpoint. Other apps may indicate targeted spray
// against specific application sign-in pages
The hunt logic: one error code (50126), many distinct accounts, a tight window, often one source. A low-and-slow spray hides under per-account lockout thresholds but reveals itself in the aggregate across accounts.
Privilege escalation: PIM bypass
// Direct role assignments — bypassing PIM
AuditLogs
| where TimeGenerated > ago(90d)
| where OperationName == "Add member to role"
| where Result == "success"
// Exclude PIM activations — these are controlled
| where OperationName != "Add member to role in PIM"
and OperationName != "Add eligible member to role in PIM"
| extend TargetUser = tostring(
TargetResources[0].userPrincipalName)
| extend InitiatedBy = coalesce(
tostring(InitiatedBy.user.userPrincipalName),
tostring(InitiatedBy.app.displayName))
| extend IPAddress = tostring(
InitiatedBy.user.ipAddress)
| extend RoleName = tostring(
parse_json(tostring(TargetResources[0].modifiedProperties))
| mv-expand p = parse_json(tostring(
TargetResources[0].modifiedProperties))
| where tostring(p.displayName) == "Role.DisplayName"
| project tostring(p.newValue))
| extend RoleSeverity = case(
RoleName has_any ("Global Administrator",
"Privileged Role Administrator",
"Privileged Authentication Administrator"),
"CRITICAL",
RoleName has_any ("Exchange Administrator",
"SharePoint Administrator",
"Security Administrator",
"Application Administrator",
"Cloud Application Administrator"),
"HIGH",
RoleName has_any ("User Administrator",
"Groups Administrator",
"Authentication Administrator"),
"MEDIUM",
"LOW")
| project TimeGenerated, TargetUser, RoleName,
RoleSeverity, InitiatedBy, IPAddress
| sort by RoleSeverity desc, TimeGenerated desc
// CRITICAL roles: Global Admin, Privileged Role Admin
// — these can assign any other role, creating cascading escalation
// HIGH roles: service-specific admins with broad data access
// Every non-PIM assignment should be verified:
// Was this authorized? Was there a change ticket?
// Is the initiator an authorized role administrator?
// Was the assignment made from a baseline IP?
The hunt logic: a privileged role gained by direct assignment rather than a PIM activation is an escalation that skipped the controlled path. In an environment that uses PIM, direct role adds are the anomaly worth hunting.
Pre-ransomware reconnaissance (endpoint)
DeviceProcessEvents
| where TimeGenerated > ago(30d)
| where FileName in~ ("nltest.exe", "net.exe",
"dsquery.exe", "csvde.exe", "ldifde.exe")
| where ProcessCommandLine has_any (
"domain_trusts", "dclist",
"group \"Domain Admins\"",
"group \"Enterprise Admins\"",
"group \"Schema Admins\"",
"group \"Backup Operators\"")
| where DeviceName !in~ ("YOURPADMIN01", "YOURPADMIN02")
// Exclude your known admin workstations
| project TimeGenerated, DeviceName, AccountName,
FileName, ProcessCommandLine,
ParentProcess = InitiatingProcessFileName
| sort by TimeGenerated desc
The hunt logic: ransomware operators enumerate the domain (trusts, DCs, Domain Admins) before they encrypt, using built-in tools. Catching the recon is catching the intrusion in the window before impact.
Cloud persistence: attacker-created inbox rules
// Non-Outlook inbox rule creation — attacker creation paths
let detectionWindow = 30d;
CloudAppEvents
| where TimeGenerated > ago(detectionWindow)
| where ActionType == "New-InboxRule"
| extend RuleDetails = parse_json(RawEventData)
| extend RuleName = tostring(RuleDetails.Parameters
| mv-expand p = RuleDetails.Parameters
| where tostring(p.Name) == "Name"
| project tostring(p.Value))
| extend ClientInfo = tostring(RuleDetails.ClientInfoString)
| extend UserAgent = tostring(RuleDetails.UserAgent)
// Classify the creation path
| extend CreationPath = case(
ClientInfo has "Client=OWA", "Outlook Web App",
ClientInfo has "Client=OutlookDesktop"
or ClientInfo has "Client=Outlook", "Outlook Desktop",
ClientInfo has "Client=OutlookMobile", "Outlook Mobile",
ClientInfo has "Client=REST"
or ClientInfo has "Client=Microsoft Graph", "Graph API",
ClientInfo has "Client=EWS", "Exchange Web Services",
ClientInfo has "Client=PowerShell"
or ClientInfo has "Client=ExchangeManagement", "PowerShell",
isempty(ClientInfo), "Unknown (no client info)",
strcat("Other: ", ClientInfo))
// Flag non-Outlook creation paths
| extend IsSuspiciousPath = CreationPath in (
"Graph API", "Exchange Web Services", "PowerShell",
"Unknown (no client info)")
| project
TimeGenerated,
AccountDisplayName,
AccountObjectId,
CreationPath,
IsSuspiciousPath,
RuleName,
RuleDetails,
IPAddress,
City = tostring(parse_json(
tostring(RuleDetails.ClientIP_GeoInfo)).City),
Country = tostring(parse_json(
tostring(RuleDetails.ClientIP_GeoInfo)).Country)
| sort by IsSuspiciousPath desc, TimeGenerated desc
// IsSuspiciousPath == true → investigate the rule content
// IsSuspiciousPath == false → Outlook client — normal unless
// the rule content is suspicious (next query)
The hunt logic: this hunts via the Graph API rather than Outlook, a creation path that bypasses the usual mailbox-rule auditing. A rule created by a non-Outlook client is the anomaly.
Email: internal BEC against a 30-day baseline
// Per-user outbound email profile — 30-day baseline
let baselineWindow = 30d;
EmailEvents
| where TimeGenerated > ago(baselineWindow)
| where EmailDirection in ("Outbound", "Intra-org")
| where SenderFromAddress !has "noreply"
and SenderFromAddress !has "mailer-daemon"
| summarize
TotalSent = count(),
DailyAvg = round(todouble(count()) / 30.0, 1),
UniqueRecipients = dcount(RecipientEmailAddress),
UniqueExternalDomains = dcount(
extract(@"@(.+)$", 1, RecipientEmailAddress)),
TypicalHours = make_set(hourofday(TimeGenerated), 24),
RecipientSet = make_set(RecipientEmailAddress, 200)
by SenderFromAddress
// Each row = one user's normal sending profile
// DailyAvg = how many emails they send per day (typically 10-50)
// UniqueRecipients = how many different people they email
// RecipientSet = the set of people they normally email
// An email to a recipient NOT in RecipientSet is a new contact
// — which is normal (new business relationship) or suspicious
// (attacker emailing someone the user has never contacted)
The hunt logic: this profiles each user's normal outbound pattern, then flags the account whose sending behavior breaks its own baseline, the tell of a compromised internal mailbox sending fraud.
Lateral movement: cloud token reuse
let timeWindow = 30m;
SigninLogs
| where TimeGenerated > ago(7d)
| where ResultType == "0"
| summarize
UniqueApps = dcount(AppDisplayName),
Apps = make_set(AppDisplayName, 20),
IPs = make_set(IPAddress, 5),
TimeSpan = datetime_diff(
'minute', max(TimeGenerated), min(TimeGenerated))
by UserPrincipalName, bin(TimeGenerated, timeWindow)
| where UniqueApps >= 8 and TimeSpan project UserPrincipalName, UniqueApps, TimeSpan,
Apps, IPs
| sort by UniqueApps desc
The hunt logic: this catches the same session token appearing across identities or IPs inside a short window, the signature of a stolen token being replayed to move laterally.
Data exfiltration: bulk downloads vs baseline
let baseline = CloudAppEvents
| where TimeGenerated between (ago(30d) .. ago(1d))
| where ActionType == "FileDownloaded"
| summarize DailyCount = count()
by AccountDisplayName, bin(TimeGenerated, 1d)
| summarize
AvgDaily = round(avg(DailyCount), 1),
StdDev = round(stdev(DailyCount), 1),
MaxDaily = max(DailyCount),
ActiveDays = dcount(bin(TimeGenerated, 1d))
by AccountDisplayName;
The hunt logic: this compares a user's download volume against their own 30-day baseline, surfacing the spike that signals staging for exfiltration rather than a fixed threshold everyone trips.
The hunt funnel
A hunt narrows from broad signal to confident finding. Each stage removes noise the previous stage could not.
- Cast the broad query. The hypothesis as KQL, scoped to the right table, window, and population.
- Enrich. Add context the raw event lacks: location, device, role, asset value, related alerts. Five enrichment dimensions turn a row into a lead.
- Baseline. Compare the candidate against normal, per entity, not against a fixed line. The deviation is the signal.
- Score confidence. Weight the indicators. A high-risk sign-in plus a directory change plus an unfamiliar IP is a different confidence than any one alone.
- Resolve. Escalate the confirmed finding to an incident, or document the clean result as coverage. Either way the hunt closes.
Hypothesis: an external actor is spraying common passwords across many accounts. Broad query: 50126 failures in the last 24h. Enrich: resolve source IPs to ASN and country, flag those outside the org's footprint. Baseline: is this volume of failures normal for these accounts, or a spike? Score: one source IP, 40 distinct accounts, one hour, foreign ASN, and one subsequent success is high confidence.
Resolve: the single 50126-to-success account is the likely compromise, escalate it as an incident and pivot to what that session did. The 39 that stayed failed are the spray's noise, documented and closed.
Running it as a program
One-off hunts find one-off things. A hunt program compounds: a prioritized backlog of hypotheses, a regular cadence, and documentation so a clean hunt becomes recorded coverage and a successful one becomes a new detection rule. The output of hunting is not just findings, it is better detections and a map of where you have looked.
Quick lookup
| Hunt objective | Table + technique |
|---|---|
| Credential attack (spray/stuffing) | SigninLogs, cluster by error code + source, baseline failure rate |
| Anomalous sign-in | SigninLogs, leftanti vs 30d IP baseline; make-series per user |
| Privilege escalation | AuditLogs, direct role adds outside PIM |
| Cloud persistence | CloudAppEvents / AuditLogs, OAuth grants, app credentials, inbox rules |
| Lateral movement | DeviceLogonEvents, multi-hop logon chains by account |
| Data exfiltration | CloudAppEvents bulk download; EmailEvents external send; baseline per user |
| Pre-ransomware | DeviceProcessEvents, recon LOLBins (nltest/net/dsquery) + shadow-copy deletion |
| Insider threat | Cross-table per-entity baseline; access outside role and hours |
From running hunts to building the hunt program
This cheatsheet is the starting set. M365 Threat Hunting teaches the discipline: writing testable hypotheses, the advanced KQL, the hunts across every objective, and building the program that turns hunting into coverage and new detections.
Explore the course