Threat hunting is not “run more queries until something looks weird.” It is a disciplined loop: form a hypothesis grounded in a specific adversary technique, write a query that would falsify or confirm it, triage the residual, and either close the hunt or promote it into a durable detection. Microsoft Sentinel and Microsoft Defender XDR give you the substrate — KQL (Kusto Query Language) over a data lake of security telemetry, the Hunts experience, UEBA enrichment, livestream, and Jupyter notebooks via MSTICPy — but none of that is a program on its own. The value comes from how you wire the substrate into a repeatable playbook that a rotating SOC team can run next quarter and get the same rigour.
The premise that runs through everything below: every hunt starts as a written hypothesis tied to a MITRE ATT&CK technique ID, every successful hunt ends as a tracked artifact (a bookmark, a tuned analytics rule, or a watchlist), and nothing is one-off. If you cannot re-run it next quarter without re-deriving it from scratch, it was a demo, not a playbook. This article is the hunting program I stand up for teams that are drowning in alert queue and want to get ahead of the adversary rather than perpetually behind the last incident — the methodology, the ATT&CK plumbing, the tables you actually query, the KQL patterns that surface the five or six behaviours that matter, the UEBA that does baselining for you, the notebooks that turn a query into an investigation, and the operational machinery that stops good hunts from evaporating.
Read the prose once for the why; then keep the tables open. This is a reference you return to at 02:00 mid-hunt, so the technique-to-table mappings, the event-ID lists, the ResultType codes, the KQL operator cheat-sheets and the tier comparisons are all laid out as scannable tables. A hunter who memorises which table carries which signal, and which KQL primitive surfaces which behaviour, is three moves ahead of one who greps documentation while the adversary dwells.
What problem this solves
Detections answer questions you already knew to ask. They are precise, tuned, and — by construction — blind to anything you did not anticipate. The gap between “what my rules catch” and “what an adversary can do” is the dwell time window, and it is where breaches live: the 2024-era industry medians still put attacker dwell in the days-to-weeks range for many intrusions, most of that spent doing things no alert fired on. Threat hunting is the deliberate, human-led practice of closing that gap — proactively looking for the techniques your automated detections miss, before they become an incident.
Without a hunting program, three failure modes recur. First, coverage theatre: a green MITRE ATT&CK heatmap the CISO shows the board, where “covered” means “a rule exists” — never mind that half the rules are disabled, noisy, or have never fired usefully. Second, one-shot brilliance: a senior analyst finds something real on a Friday, fixes it, and the knowledge lives in their head; the same technique walks back in six months and nobody remembers the query. Third, tool-driven flailing: analysts run ad-hoc KQL with no hypothesis, chasing whatever looks odd, producing no measurable coverage and no reproducible artifact — motion without progress.
Who hits this: every SOC running Sentinel and/or Defender XDR at scale. It bites hardest on teams with large, high-volume telemetry (endpoint, sign-in, and cloud-audit logs measured in hundreds of GB/day) where the interesting signal is a needle in noise, on teams under E5/A5 licensing who have the data but not the practice, and on any org whose board metric is “techniques covered” rather than “techniques covered and validated in the last quarter.” The fix is not a product purchase — it is a methodology, a set of KQL patterns, and the operational discipline to promote what works and retire what doesn’t.
To frame the field before the deep dive, here is every hunt type this article equips you to run, the ATT&CK ground it covers, and the primary substrate it runs on:
| Hunt class | What you are looking for | Primary ATT&CK tactics | Primary tables | Substrate |
|---|---|---|---|---|
| Rare / low-prevalence | Processes, binaries, or connections seen on very few hosts | Execution, Persistence, Defense Evasion | DeviceProcessEvents, DeviceImageLoadEvents |
Defender XDR / Sentinel |
| Beaconing / C2 | Regular, machine-like outbound intervals | Command and Control, Exfiltration | DeviceNetworkEvents, CommonSecurityLog |
Defender XDR / Sentinel |
| Lateral movement | Unusual host-to-host auth, RDP/SMB/WinRM spread | Lateral Movement, Discovery | SecurityEvent, DeviceLogonEvents, IdentityLogonEvents |
Sentinel / Defender for Identity |
| Credential access | Kerberoasting, DCSync, LSASS access, spraying | Credential Access | SecurityEvent, SigninLogs, DeviceEvents |
Sentinel / Defender XDR |
| Identity / ATO | Anomalous sign-ins, new geo/ASN, token theft | Initial Access, Persistence, Defense Evasion | SigninLogs, AADNonInteractiveUserSignInLogs, BehaviorAnalytics |
Sentinel UEBA |
| Cloud / SaaS abuse | Consent grants, forwarding rules, mass download | Persistence, Collection, Exfiltration | OfficeActivity, CloudAppEvents, AuditLogs |
Sentinel / Defender for Cloud Apps |
| Insider / peer-outlier | Legitimate access that is anomalous for the peer group | Collection, Exfiltration | BehaviorAnalytics, UserPeerAnalytics |
Sentinel UEBA |
Learning objectives
By the end of this article you can:
- Run a hypothesis-driven hunt end to end: write a falsifiable, ATT&CK-anchored hypothesis, translate it into parameterized KQL, triage the residual, and decide its exit (bookmark, detection, or drop).
- Structure a hunting program with the PEAK framework and the Hunting Maturity Model, distinguishing hypothesis-driven, baseline (exploratory), and model-assisted hunts.
- Map every hunt to MITRE ATT&CK tactics and techniques as structured metadata, and track effective coverage (rules that fired and were triaged) rather than merely intended coverage.
- Fluently query the core hunting tables —
SecurityEvent,SigninLogs,DeviceProcessEvents,OfficeActivityand their Defender-portal equivalents — and know which event IDs, columns, and result codes carry the signal. - Write the canonical KQL hunting patterns: rare-process (stack counting / least-frequency), beaconing via inter-arrival time-series, lateral movement, credential access (Kerberoasting, DCSync, spraying, LSASS), and exfiltration.
- Exploit UEBA: query
BehaviorAnalytics,IdentityInfo,UserPeerAnalytics, readInvestigationPriority, and use entity pages for account-takeover and insider hunts. - Operationalize hunts with MSTICPy notebooks, bookmarks, hunting livestream, and promotion to scheduled/NRT analytics rules or Defender custom detections.
- Hunt at scale over high-volume logs using summary rules, Auxiliary/Basic table tiers, and Azure Data Explorer (ADX) cross-service queries, and reason about the cost.
Prerequisites & where this fits
You should already be comfortable with KQL fundamentals — where, summarize, join, project, extend, mv-expand, and time-bucketing with bin() and make-series. If those are shaky, read KQL for Azure Monitor and Log Analytics: From Joins to Time-Series, Without Blowing the Budget and KQL That Earns Its Keep: summarize, join, make-series and Time-Bucketing Azure Logs first; this article assumes them and spends its budget on the hunting layer above. You should know how a Log Analytics workspace is structured (tables, retention, RBAC) — see How to Create a Log Analytics Workspace — because a Sentinel workspace is a Log Analytics workspace with the Sentinel solution enabled.
You should understand, at least in outline, the ATT&CK matrix (tactics as columns, techniques as cells) and the Microsoft security portfolio: Sentinel (cloud SIEM/SOAR on Log Analytics), Defender XDR (the unified XDR portal spanning endpoint, identity, email, and cloud-app signals via advanced hunting), Defender for Identity (on-prem AD/Entra Connect signal), and Defender for Cloud Apps (SaaS/CASB). This article sits at the top of the detection-engineering track: hunting is where new detections are born before they graduate into the standing rule set. It pairs with Operationalizing Entra ID Protection (which supplies risk signals you hunt around), Governing OAuth Consent and Application Permissions in Entra ID (the consent-grant attack surface), Taming Shadow IT and Risky SaaS, and Engineering Incident Response: Runbooks, Tabletop Exercises, and Cloud Forensics (where a confirmed hunt hands off). For infrastructure-as-code onboarding of the SIEM itself, see Terraform Module: Azure Microsoft Sentinel.
A quick map of who owns which layer during a hunt, so escalation is fast:
| Layer | What lives here | Who usually owns it | Hunt classes it feeds |
|---|---|---|---|
| Endpoint (MDE) | Process, file, registry, network, image-load events | Endpoint / EDR team | Rare-process, beaconing, credential access |
| Identity (Entra + MDI) | Sign-ins, directory changes, on-prem auth | IAM / identity team | ATO, lateral movement, credential access |
| Email + collab (MDO) | Mail flow, URL clicks, attachments | Messaging / SecOps | Phishing, initial access, collection |
| Cloud apps (MDCA) | SaaS audit, OAuth grants, file activity | Cloud security | Consent abuse, exfiltration, insider |
| SIEM (Sentinel) | Correlated logs, UEBA, watchlists, rules | SOC / detection engineering | All — the correlation plane |
| Notebooks (MSTICPy) | Ad-hoc investigation, enrichment, ML | Threat-hunt / IR team | Deep-dive on any confirmed lead |
Core concepts
Six mental models make every later query obvious.
A hunt is a hypothesis test, not a search. The unit of work is not “a query” but “a claim about adversary behaviour that data can refute.” “Show me all PowerShell” is a search; “If an operator moved laterally with a stolen credential in the last 14 days, then I will see runas /netonly-style NewCredentials logons (type 9) from a workstation to a server that this account has never touched” is a hypothesis. The difference is falsifiability: a hypothesis names the table, the signal, and the condition under which you would close the hunt as negative. That discipline is what lets you measure coverage and avoid infinite exploratory drift.
The status code names the platform’s view, not the truth. Sentinel and Defender surface signal, not verdict. A high InvestigationPriority, a UEBA “first time from country,” a rare binary — these are leads, not confirmations. Every hunt produces a residual that a human must adjudicate as benign or malicious. The skill is shrinking the residual to something a person can read in one screen, then deciding whether what remains is explainable.
Sentinel and Defender XDR are two front-ends over overlapping data. Sentinel’s tables (SecurityEvent, SigninLogs, OfficeActivity, CommonSecurityLog, plus UEBA) live in a Log Analytics workspace and you query them in the Logs/Hunting blade. Defender XDR’s advanced hunting tables (Device*, Email*, Identity*, Cloud*, Alert*, UrlClickEvents) live in the Defender data store with 30-day retention and no separate ingestion charge. Since the unified SOC platform (Sentinel onboarded into the Defender portal, GA 2024), advanced hunting in the Defender portal can query both sets in one query — you can join a DeviceProcessEvents row to a SigninLogs row. Know which store a table lives in; it drives retention, cost, and where you write the query.
Telemetry is a pyramid of pain. David Bianco’s Pyramid of Pain ranks indicators by how much they cost the adversary to change: hash values and IPs are trivial to rotate (bottom), while TTPs (tools, tactics, techniques, procedures) are expensive (top). Good hunts target the top of the pyramid — behaviours like “process spawned from Office with an encoded command line,” not “this specific SHA256” — because behaviour-based hunts survive the adversary swapping infrastructure. Every KQL pattern below is deliberately behavioural.
UEBA does the baselining you would otherwise script. “Is this normal for this user/host?” is the recurring hunting question, and computing per-entity baselines by hand is expensive. Sentinel’s UEBA engine ingests SigninLogs, AuditLogs, SecurityEvent, and more, and writes enriched, baselined signal to queryable tables (BehaviorAnalytics chief among them), folding dozens of behavioural features into one InvestigationPriority score. Hunting against pre-baselined data is dramatically faster than rolling your own series_decompose for every entity.
Coverage is the program metric. A hunt’s worth to the program is the ATT&CK ground it retires. Tagging every hunt and rule with tactics and technique IDs turns a pile of queries into a measurable map — but only if you measure effective coverage (a rule that fired and was triaged) and not intended coverage (a rule that merely exists). The two diverge fast and the gap is where breaches hide.
The vocabulary in one table
Pin the moving parts before the deep sections; the glossary repeats these for lookup:
| Term | One-line definition | Where it lives | Why it matters |
|---|---|---|---|
| Hypothesis | A falsifiable claim about adversary behaviour | Your hunt record | The unit of a real hunt |
| KQL | Kusto Query Language — read-only analytics over logs | Sentinel + Defender | The one language for all hunting |
| Hunt (experience) | Sentinel object: hypothesis + queries + bookmarks + status | Sentinel Hunting blade | Persists a hunt as a tracked artifact |
| Bookmark | A saved query-result row with notes + entity mappings | HuntingBookmark table |
The unit of evidence |
| Livestream | Near-real-time test of a hunting query against live data | Sentinel Hunting | Validate a query before it’s a rule |
| BehaviorAnalytics | UEBA output: per-activity enrichment + priority score | Log Analytics | The fastest anomaly signal |
| InvestigationPriority | 0–10 UEBA score folding many behavioural features | BehaviorAnalytics |
Rank leads in one number |
| MSTICPy | Microsoft’s open-source Python hunting library | Jupyter notebook | Turns a query into an investigation |
| Analytics rule | A saved query that runs on a schedule and raises incidents | Sentinel | The promotion target for a proven hunt |
| Summary rule | Scheduled aggregation of high-volume data into a compact table | Sentinel | Hunt cheaply at scale |
| ATT&CK technique | A specific adversary behaviour with a T-ID | MITRE matrix | The anchor that makes hunts measurable |
The hunting methodologies: hypothesis-driven and PEAK
A methodology is what stops hunting from being a talented individual’s hobby. Three related frameworks give the program its shape.
The Sqrrl hunting loop
The original, still the clearest: create a hypothesis → investigate via tools and techniques → uncover new patterns and TTPs → inform and enrich automated analytics, then loop. The last arrow is the one teams drop — a hunt that finds something must feed back into detections, threat intel, and the next hypothesis, or the loop is broken and you re-hunt the same ground forever.
PEAK: Prepare, Execute, Act with Knowledge
PEAK (the framework popularised by Splunk’s SURGe team) organises each hunt into three phases and recognises three types of hunt. The phases:
- Prepare — pick a topic, research the technique (ATT&CK, threat intel, your own environment’s exposure), state the hypothesis, and confirm you have the data to test it (no point hunting T1003.006 DCSync if you don’t collect 4662 on your DCs).
- Execute — gather and analyse the data, iterate the query, triage the residual.
- Act with Knowledge — document findings, promote to detection, update baselines, and hand new TTPs back to intel. The knowledge is the durable output, whether or not you found a live adversary.
The three PEAK hunt types are the practical menu — pick the one that fits your data and maturity:
| PEAK hunt type | You use it when… | Method | Output | Example |
|---|---|---|---|---|
| Hypothesis-driven | You have a specific technique in mind | Test a falsifiable claim with targeted KQL | Confirm/refute + a detection candidate | “Kerberoasting via RC4 tickets (T1558.003)” |
| Baseline (exploratory data analysis) | You don’t yet know what normal looks like | Profile a data set, find the shape, flag outliers | A baseline + anomalies worth chasing | “Profile all outbound by process; what stands out?” |
| Model-Assisted (M-ATH) | Volume/complexity defeats eyeballing | ML/statistics (clustering, anomaly scoring) surface candidates | A model + scored leads | “Cluster failed-logon patterns with autocluster()” |
The Hunting Maturity Model
Bianco’s Hunting Maturity Model (HMM) tells you where your program stands and what to build next:
| Level | Name | Analytics | Hunting capability |
|---|---|---|---|
| HMM0 | Initial | Relies on automated alerting only | No hunting; alerts drive everything |
| HMM1 | Minimal | Incorporates threat-intel indicators | Searches for known IOCs |
| HMM2 | Procedural | Follows others’ hunt procedures | Runs published hunts, little novelty |
| HMM3 | Innovative | Creates new hunt procedures | Develops its own hypotheses and analytics |
| HMM4 | Leading | Automates successful hunts into detections | Continuously converts hunts to automation |
The goal is HMM3→HMM4: a team that invents hunts and automates the ones that work. The tell that you’re stuck at HMM1/2 is a backlog full of “search for these IOCs from the latest report” and no home-grown, behaviour-based hypotheses.
Writing a hypothesis that’s actually testable
A hunt hypothesis has three parts: the technique, the observable, and the falsifiable claim. Write it before you touch KQL.
Hypothesis (T1098.001 — Additional Cloud Credentials): “An attacker who phished an Entra admin would add a credential or federated trust to an app/service principal to persist. If that happened in the last 14 days, then I will see
Add service principal credentials, certificate/secret additions, orAdd federated identity credentialinAuditLogsfrom an unusual actor or IP. I close this hunt negative if every such event maps to a known change ticket.”
That sentence dictates the query — you are testing a claim, not exploring:
// Hunt: T1098.001 — credentials/secrets/federation added to app registrations & SPs
let lookback = 14d;
AuditLogs
| where TimeGenerated > ago(lookback)
| where OperationName in (
"Add service principal credentials",
"Update application - Certificates and secrets management",
"Add application key",
"Add federated identity credential")
| where Result == "success"
| extend Actor = tostring(InitiatedBy.user.userPrincipalName)
| extend ActorIp = tostring(InitiatedBy.user.ipAddress)
| extend TargetApp = tostring(TargetResources[0].displayName)
| where isnotempty(Actor) // exclude system / sync-driven changes
| project TimeGenerated, Actor, ActorIp, TargetApp, OperationName, Result
| sort by TimeGenerated desc
Two habits make this reusable instead of throwaway. Parameterize the volatile bits with let at the top (lookback, allow-lists, thresholds) so the next hunter tunes one block, not the whole query. Project a stable schema (Actor, ActorIp, TargetApp) so notebook enrichment, bookmarking, and entity-mapping always find the same columns. A playbook is a contract on its output shape as much as its logic. The properties of a hypothesis worth running:
| Property | Good hypothesis | Bad hypothesis |
|---|---|---|
| ATT&CK-anchored | Names a technique ID (T1558.003) | “Look for weird auth” |
| Falsifiable | States what a negative result looks like | Can never be “done” |
| Data-grounded | You already collect the table it needs | Needs telemetry you don’t have |
| Scoped in time | Bounded lookback (14d, 30d) | “All time” — unbounded cost |
| Environment-aware | Reflects your real exposure/crown jewels | Generic, ignores your assets |
| Single-claim | Tests one behaviour | Bundles five unrelated ideas |
Mapping hunts to MITRE ATT&CK and tracking coverage
Coverage is the metric that turns hunting from activity into a program. ATT&CK Enterprise organises adversary behaviour into 14 tactics (the “why” — the adversary’s goal) each containing techniques and sub-techniques (the “how”). Every saved hunting query and analytics rule in Sentinel carries tactics and technique IDs as first-class metadata; populate them deliberately and the platform draws you a map.
The 14 Enterprise tactics, in kill-chain order, with the tables where you most often hunt each:
| Tactic (ID) | Adversary goal | Primary hunt tables |
|---|---|---|
| Reconnaissance (TA0043) | Gather info to plan the attack | SigninLogs, external TI |
| Resource Development (TA0042) | Build/buy capabilities | External TI, AuditLogs |
| Initial Access (TA0001) | Get a foothold | SigninLogs, EmailEvents, OfficeActivity |
| Execution (TA0002) | Run adversary code | DeviceProcessEvents, SecurityEvent (4688) |
| Persistence (TA0003) | Keep the foothold | AuditLogs, DeviceRegistryEvents, OfficeActivity |
| Privilege Escalation (TA0004) | Gain higher permissions | SecurityEvent (4672/4728), AuditLogs |
| Defense Evasion (TA0005) | Avoid detection | SecurityEvent (1102), DeviceProcessEvents |
| Credential Access (TA0006) | Steal credentials | SecurityEvent (4769/4662), DeviceEvents, SigninLogs |
| Discovery (TA0007) | Learn the environment | DeviceProcessEvents, SecurityEvent (4661) |
| Lateral Movement (TA0008) | Move through the network | SecurityEvent (4624), DeviceLogonEvents, IdentityLogonEvents |
| Collection (TA0009) | Gather target data | OfficeActivity, CloudAppEvents |
| Command and Control (TA0011) | Communicate with implants | DeviceNetworkEvents, CommonSecurityLog |
| Exfiltration (TA0010) | Steal the data | DeviceNetworkEvents, OfficeActivity, CloudAppEvents |
| Impact (TA0040) | Destroy/disrupt/ransom | SecurityEvent, DeviceFileEvents, AuditLogs |
A pragmatic starter set of high-value techniques, the KQL signal that reveals each, and the table it lives in — this is a hunt backlog in table form:
| Technique | ATT&CK ID | KQL signal | Table |
|---|---|---|---|
| Password spraying | T1110.003 | Many accounts, few attempts/account, one IP; ResultType == 50126 |
SigninLogs |
| Kerberoasting | T1558.003 | EventID 4769, TicketEncryptionType == "0x17" (RC4), many SPNs |
SecurityEvent |
| DCSync | T1003.006 | EventID 4662 with DS-Replication GUID, non-DC account |
SecurityEvent |
| LSASS access | T1003.001 | Process opening lsass.exe handle |
DeviceEvents |
| Additional cloud creds | T1098.001 | Add service principal credentials |
AuditLogs |
| Illicit consent grant | T1528 | Consent to application to risky/new app |
AuditLogs |
| Email forwarding rule | T1114.003 | New-InboxRule with ForwardTo/RedirectTo external |
OfficeActivity |
| Beaconing C2 | T1071.001 | Low coefficient-of-variation inter-arrival times | DeviceNetworkEvents |
| RDP lateral movement | T1021.001 | EventID 4624 LogonType 10, new src→dst pair |
SecurityEvent |
| Cloud storage exfil | T1567.002 | Large FileUploaded to unsanctioned app |
CloudAppEvents |
| Audit-log cleared | T1070.001 | EventID 1102 |
SecurityEvent |
| Rare service install | T1543.003 | EventID 7045, low prevalence |
SecurityEvent |
Technique tags as structured metadata
In a scheduled analytics rule (and in saved hunting queries), the ATT&CK mapping is structured fields, not a comment. Bicep for a rule that tags the technique correctly:
resource credAddRule 'Microsoft.SecurityInsights/alertRules@2024-03-01' = {
scope: workspace
name: guid('hunt-T1098-cred-add')
kind: 'Scheduled'
properties: {
displayName: 'Credential added to service principal (T1098.001)'
severity: 'Medium'
enabled: true
query: loadTextContent('queries/t1098-sp-cred-add.kql')
queryFrequency: 'PT1H'
queryPeriod: 'P14D'
triggerOperator: 'GreaterThan'
triggerThreshold: 0
tactics: [ 'Persistence', 'PrivilegeEscalation' ]
techniques: [ 'T1098' ]
subTechniques: [ 'T1098.001' ]
entityMappings: [
{
entityType: 'Account'
fieldMappings: [ { identifier: 'FullName', columnName: 'Actor' } ]
}
]
}
}
tactics,techniques, andsubTechniquesare the fields the MITRE ATT&CK coverage blade reads.subTechniquesarrived later thantechniquesand is honoured on recentalertRulesAPI versions (2023-02-01 and newer) — pin a current@versionor the property is silently dropped and your sub-technique granularity disappears.
The metadata fields that drive coverage, and what each does:
| Field | Type | Purpose | Gotcha |
|---|---|---|---|
tactics |
array of tactic names | Shades the matrix columns | Use ATT&CK names, not IDs, here |
techniques |
array of T-IDs | Shades technique cells | T1098, not 1098 |
subTechniques |
array of T.sub-IDs | Sub-technique granularity | Needs API ≥ 2023-02-01 |
entityMappings |
array | Binds columns to entities for investigation | Wrong identifier breaks the graph |
severity |
enum | Incident priority | Not coverage, but drives triage |
queryPeriod / queryFrequency |
ISO8601 duration | Lookback vs cadence | queryPeriod ≥ queryFrequency |
Intended vs effective coverage
Open Sentinel → MITRE ATT&CK to see the matrix shaded by your active rules and hunting queries. Treat the gaps as a backlog — but treat the green cells with suspicion, because the blade measures intent to detect, not efficacy. A green cell backed by a noisy, never-tuned, or disabled rule is worse than an empty one: it lies to the board. Pull coverage programmatically to track it over time rather than eyeballing the heatmap:
# Export technique coverage from active analytics rules
az rest --method get \
--url "https://management.azure.com/subscriptions/$SUB/resourceGroups/$RG/providers/Microsoft.OperationalInsights/workspaces/$WS/providers/Microsoft.SecurityInsights/alertRules?api-version=2024-03-01" \
--query "value[?properties.enabled].properties.{name:displayName, techniques:techniques, tactics:tactics}" -o table
Then join intended coverage (a rule is tagged with the technique) against effective coverage (that rule actually fired and someone triaged it) so the two move together:
// Effective coverage: which tagged techniques actually produced alerts in 90 days?
SecurityAlert
| where TimeGenerated > ago(90d)
| mv-expand Technique = todynamic(Techniques)
| extend Technique = tostring(Technique)
| where Technique matches regex @"^T\d{4}"
| summarize Fires = count(), Rules = dcount(AlertName), LastFired = max(TimeGenerated)
by Technique
| sort by Fires asc // techniques at the top: tagged but silent — your amber list
The two coverage states you must not conflate:
| State | Definition | How you measure it | What it tells the board |
|---|---|---|---|
| Intended | A rule/hunt is tagged with the technique | Matrix blade / alertRules API |
“We meant to detect this” |
| Effective | The rule fired and was triaged in the window | SecurityAlert fire counts + incident closure |
“We actually detect this, validated” |
| Amber (the gap) | Tagged but zero useful fires in 90d | Effective join shows Fires = 0 | “Assume this is a blind spot” |
The tables you hunt in
Hunting is table-literate work: knowing which table carries which signal is most of the speed. Here is the data-source-to-tactic map — the tables you will live in, what they carry, where they live, and the tactics they light up:
| Table | Carries | Store | Tactics it lights up |
|---|---|---|---|
SecurityEvent |
Windows Security event log (via AMA/MMA) | Log Analytics | Execution, Cred Access, Lateral Movement, Priv Esc |
SigninLogs |
Interactive Entra sign-ins | Log Analytics | Initial Access, Credential Access |
AADNonInteractiveUserSignInLogs |
Token-refresh / non-interactive sign-ins | Log Analytics | Persistence, Defense Evasion |
AuditLogs |
Entra directory changes | Log Analytics | Persistence, Priv Esc |
OfficeActivity |
Exchange/SharePoint/OneDrive/Entra audit | Log Analytics | Collection, Exfiltration, Persistence |
DeviceProcessEvents |
Process creation (MDE) | Defender store | Execution, Discovery, Defense Evasion |
DeviceNetworkEvents |
Network connections (MDE) | Defender store | C2, Exfiltration |
DeviceLogonEvents |
Endpoint logons (MDE) | Defender store | Lateral Movement |
IdentityLogonEvents |
On-prem/hybrid auth (MDI) | Defender store | Lateral Movement, Credential Access |
EmailEvents |
Mail flow (MDO) | Defender store | Initial Access, Collection |
CloudAppEvents |
SaaS audit (MDCA) | Defender store | Exfiltration, Persistence |
BehaviorAnalytics |
UEBA enrichment + priority | Log Analytics | All (as anomaly overlay) |
Because the same conceptual signal often has two table names (Sentinel vs Defender store), keep this Rosetta stone handy — it saves you rewriting a query when you move between portals:
| Signal | Sentinel table (Log Analytics) | Defender advanced-hunting table |
|---|---|---|
| Entra interactive sign-in | SigninLogs |
AADSignInEventsBeta (preview) |
| Windows process creation | SecurityEvent (EventID 4688) |
DeviceProcessEvents |
| Endpoint logon | SecurityEvent (EventID 4624) |
DeviceLogonEvents |
| On-prem AD auth | SecurityEvent (DCs) |
IdentityLogonEvents |
| Email delivery | EmailEvents (if connected) |
EmailEvents |
| Cloud-app audit | OfficeActivity / McasShadowItReporting |
CloudAppEvents |
| Alerts | SecurityAlert |
AlertInfo + AlertEvidence |
SecurityEvent: the Windows security log
SecurityEvent is the richest single table for endpoint and domain hunting — the Windows Security event log forwarded by the Azure Monitor Agent (AMA) via a Data Collection Rule. The event IDs you hunt by:
| EventID | Meaning | Hunt use |
|---|---|---|
| 4624 | Successful logon | Lateral movement (by LogonType), new src→dst pairs |
| 4625 | Failed logon | Spraying/brute force (by count, IP) |
| 4648 | Logon with explicit credentials | runas, credential reuse |
| 4672 | Special privileges assigned | Privileged logon tracking |
| 4688 | Process creation (+ command line if audited) | Execution, LOLBins, encoded commands |
| 4720 | User account created | Persistence |
| 4728 / 4732 / 4756 | Member added to security-enabled group | Priv esc (e.g. Domain Admins) |
| 4768 / 4769 | Kerberos TGT / service ticket requested | Kerberoasting, golden/silver ticket |
| 4771 | Kerberos pre-auth failed | AS-REP roasting, brute force |
| 4776 | NTLM credential validation | Pass-the-hash, spraying |
| 4662 | Operation on an AD object | DCSync (with replication GUID) |
| 4661 | Handle to an object requested | Discovery / SAMR enumeration |
| 5140 / 5145 | Network share accessed (detailed) | Lateral movement, collection |
| 7045 | Service installed | Persistence, rare-service hunts |
| 1102 | Audit log cleared | Defense evasion |
| 4104 | PowerShell script block logged | Obfuscated execution (separate channel) |
Logon types on 4624/4625 are the single most useful discriminator in lateral-movement hunts — memorise them:
| LogonType | Name | Means | Hunt relevance |
|---|---|---|---|
| 2 | Interactive | At the console | Physical/RDP console |
| 3 | Network | SMB, file share, WinRM | Lateral movement, share access |
| 4 | Batch | Scheduled task | Persistence execution |
| 5 | Service | Service start | Service-account abuse |
| 7 | Unlock | Workstation unlock | Low value |
| 8 | NetworkCleartext | Creds sent in cleartext | IIS basic auth, risky |
| 9 | NewCredentials | runas /netonly |
Lateral movement with alt creds |
| 10 | RemoteInteractive | RDP | RDP lateral movement |
| 11 | CachedInteractive | Cached domain creds | Offline logon |
SigninLogs: identity is the new perimeter
SigninLogs carries interactive Entra sign-ins; AADNonInteractiveUserSignInLogs carries token-refresh and background auth (often the larger and more interesting volume for token-theft hunts). Key columns:
| Column | Carries | Hunt use |
|---|---|---|
UserPrincipalName |
The account | Entity pivot |
AppDisplayName |
Target app | Which app is being abused |
IPAddress / Location |
Source IP + geo | Impossible travel, new geo |
ResultType |
Numeric outcome code | Success/failure classification |
ClientAppUsed |
Legacy vs modern auth | Legacy-auth (no MFA) hunts |
AuthenticationRequirement |
SFA vs MFA | MFA-bypass hunts |
ConditionalAccessStatus |
CA outcome | Policy-gap hunts |
RiskLevelDuringSignIn / RiskState |
Entra ID Protection risk | Risk-correlated hunts |
DeviceDetail |
OS/browser/compliance | Unmanaged-device hunts |
AuthenticationDetails |
Per-factor result | MFA-fatigue / failed-factor hunts |
The ResultType codes you actually branch on:
| ResultType | Meaning | Hunt relevance |
|---|---|---|
| 0 | Success | Baseline; the “it worked” set |
| 50126 | Invalid username or password | Spraying / brute force |
| 50053 | Account locked (smart lockout) | Aggressive brute force |
| 50055 | Password expired | Low value |
| 50057 | Account disabled | Attempts on dead accounts = recon |
| 50074 | Strong auth (MFA) required | MFA challenged |
| 50076 | MFA required by CA | Policy enforced |
| 50079 | MFA registration required | New-device / new-user |
| 50158 | External security challenge not satisfied | CA / external auth |
| 53003 | Blocked by Conditional Access | Policy blocked the attempt |
| 65001 | Consent granted | Illicit consent correlation |
| 530032 | Blocked by security defaults | Legacy auth blocked |
DeviceProcessEvents: endpoint execution
DeviceProcessEvents (MDE advanced hunting) is where execution, discovery, and defense-evasion hunts live. The columns that carry signal:
| Column | Carries | Hunt use |
|---|---|---|
FileName / FolderPath |
The process image + path | LOLBins from odd paths |
SHA256 / SHA1 / MD5 |
Hashes | Prevalence, IOC match |
ProcessCommandLine |
Full command line | Encoded commands, flags |
ProcessId |
PID | Process-tree correlation |
AccountName / AccountDomain |
Runs-as identity | Whose context |
InitiatingProcessFileName |
Parent process | Office→cmd, unusual parents |
InitiatingProcessCommandLine |
Parent command line | Full attack chain |
InitiatingProcessParentFileName |
Grandparent | Three generations of the tree |
DeviceName / DeviceId |
The host | Prevalence, entity pivot |
The wider Defender-store table families, so you know where to reach:
| Family | Tables | Signal |
|---|---|---|
| Device | DeviceProcessEvents, DeviceNetworkEvents, DeviceFileEvents, DeviceRegistryEvents, DeviceLogonEvents, DeviceImageLoadEvents, DeviceEvents, DeviceInfo, DeviceNetworkInfo |
Endpoint (MDE) |
EmailEvents, EmailAttachmentInfo, EmailUrlInfo, EmailPostDeliveryEvents, UrlClickEvents |
Mail + collab (MDO) | |
| Identity | IdentityLogonEvents, IdentityQueryEvents, IdentityDirectoryEvents |
On-prem/hybrid identity (MDI) |
| Cloud | CloudAppEvents |
SaaS audit (MDCA) |
| Alerts | AlertInfo, AlertEvidence |
Cross-product alerts + evidence |
OfficeActivity: the collaboration audit
OfficeActivity unifies the Microsoft 365 unified audit log (Exchange, SharePoint, OneDrive, and some Entra events) in Sentinel. The operations that matter for hunting:
Operation |
Workload | Technique it reveals |
|---|---|---|
New-InboxRule / Set-InboxRule / UpdateInboxRules |
Exchange | T1114.003 forwarding-rule exfil |
Add-MailboxPermission |
Exchange | T1098 mailbox delegation |
MailItemsAccessed |
Exchange | T1114 mail collection (throttled signal) |
Send / SendAs / SendOnBehalf |
Exchange | Impersonation, BEC |
FileDownloaded / FileSyncDownloadedFull |
SharePoint/OneDrive | Mass-download exfil |
FileUploaded |
SharePoint/OneDrive | Staging / exfil to cloud |
SharingSet / AnonymousLinkCreated |
SharePoint/OneDrive | Data exposure, exfil |
Add member to role |
Entra (via O365) | Privilege escalation |
Update StsRefreshTokenValidFrom Timestamp |
Entra | Token revocation / session reset abuse |
Consent to application |
Entra | T1528 illicit consent |
KQL hunting query patterns
Hunters live in a different subset of KQL than detection authors: detections want precision, hunts want to surface the weird. First, the operator cheat-sheet — the primitives that do most of the exploratory work:
| Operator / function | What it does | Hunt use |
|---|---|---|
make-series |
Aggregate into an evenly spaced time series | Precondition for all time-series stats |
series_decompose_anomalies() |
Seasonal+trend decomposition, flag residual outliers | Per-entity anomaly vs its own baseline |
series_outliers() |
Tukey-test point-anomaly score | Cheap point outliers, no seasonality |
series_periods_detect() |
Find dominant periods in a series | Beaconing periodicity |
evaluate autocluster() |
Smallest set of common patterns | “What’s the dominant shape; what’s outside it” |
evaluate basket() |
Frequent-itemset mining | Co-occurring attribute clusters |
prev() / next() (with serialize) |
Row-relative access | Inter-arrival deltas, sessionization |
dcount() / make_set() |
Cardinality / distinct collection | Prevalence, stack counting |
arg_max() / arg_min() |
Latest/earliest row per key | Newest identity state |
mv-expand / mv-apply |
Fan out / process dynamic arrays | Parse Parameters, multi-value fields |
join kind=inner/leftouter/leftanti |
Correlate/enrich/subtract | Entity pivots, “never-seen-before” |
externaldata / _GetWatchlist() |
Pull reference data | Allow-lists, TI, known-good |
Two structural rules that separate a working hunt query from a broken one. First, make-series needs default=0 and a bounded from/to — without them, gaps in activity silently distort the series and every downstream statistic. Second, put the smaller, filtered set on the left of a join — Kusto loads the left side, so filter early and hard.
Rare-process hunting (stack counting / least-frequency of occurrence)
The oldest hunting technique and still one of the best: stack counting. Aggregate an attribute across the fleet and look at the tail — the processes, binaries, or paths that appear on very few hosts. Malware, by definition, has low prevalence.
// Rare-process hunt: binaries seen on <= 3 devices, outside standard paths (T1059/T1204)
let lookback = 7d;
let rareThreshold = 3;
DeviceProcessEvents
| where Timestamp > ago(lookback)
| where FolderPath !startswith @"C:\Program Files"
and FolderPath !startswith @"C:\Windows\System32"
and FolderPath !startswith @"C:\Windows\SysWOW64"
| summarize DeviceCount = dcount(DeviceId),
FirstSeen = min(Timestamp),
SampleCmds = make_set(ProcessCommandLine, 5),
Paths = make_set(FolderPath, 5)
by FileName, SHA256
| where DeviceCount <= rareThreshold
| order by DeviceCount asc, FirstSeen desc
The tuning knobs and their trade-offs:
| Knob | Effect | Lower it to… | Raise it to… |
|---|---|---|---|
rareThreshold (device count) |
How rare counts as “rare” | Cut noise, miss slow-spreading tools | Catch broader spread, more noise |
lookback |
Baseline window | Faster/cheaper, misses slow campaigns | Stronger baseline, higher cost |
| Path exclusions | Suppress known-good locations | (more noise) | Focus on user-writable/odd paths |
Group key (FileName vs SHA256) |
Prevalence granularity | Hash: catches renamed binaries | Name: catches recompiled variants |
AccountName filter |
Whose context | Focus on privileged users | Whole fleet |
The refinement that makes stack counting pay off: don’t just count rarity, subtract your known-good. Maintain a baseline of approved binaries (from a golden image or a 30-day historical set) and use join kind=leftanti to keep only processes never seen in the baseline — a true “first observed in the environment” hunt.
Beaconing detection via inter-arrival time-series
Command-and-control channels beacon home on a regular cadence. Humans and normal apps generate irregular traffic; a beacon generates machine-regular intervals. The tell is a low coefficient of variation (standard deviation ÷ mean) of the inter-arrival times between a host and a destination.
// Beaconing hunt: regular outbound intervals per (device, remote IP) (T1071.001 / T1571)
let lookback = 3d;
let minConnections = 20; // need enough events to judge regularity
let maxCoV = 0.15; // <= 0.15 is suspiciously regular
DeviceNetworkEvents
| where Timestamp > ago(lookback)
| where ActionType == "ConnectionSuccess" and RemotePort in (80, 443, 8080, 53)
| where InitiatingProcessFileName !in~ ("chrome.exe","msedge.exe","firefox.exe","OneDrive.exe","teams.exe")
| project Timestamp, DeviceId, RemoteIP, InitiatingProcessFileName
| order by DeviceId, RemoteIP, Timestamp asc
| serialize
| extend PrevTime = prev(Timestamp),
SameStream = (DeviceId == prev(DeviceId) and RemoteIP == prev(RemoteIP))
| where SameStream
| extend DeltaSec = datetime_diff('second', Timestamp, PrevTime)
| summarize ConnCount = count(),
AvgIntervalSec = avg(DeltaSec),
StdevSec = stdev(DeltaSec),
Process = any(InitiatingProcessFileName)
by DeviceId, RemoteIP
| where ConnCount >= minConnections and AvgIntervalSec > 0
| extend CoefficientOfVariation = round(StdevSec / AvgIntervalSec, 3)
| where CoefficientOfVariation <= maxCoV
| order by CoefficientOfVariation asc
An alternate approach uses make-series on connection counts per bin and series_periods_detect() to surface the dominant period directly — useful when beacons “jitter” but retain an underlying cadence. The parameters that govern a beaconing hunt and how to reason about each:
| Parameter | Meaning | Too low | Too high |
|---|---|---|---|
minConnections |
Minimum events to judge regularity | Statistically meaningless CoV | Miss short-lived beacons |
maxCoV |
Regularity threshold | Miss jittered beacons | Flood of benign polling |
| Process allow-list | Exclude known chatty apps | Drowns in browser/telemetry noise | Might exclude a hijacked binary |
RemotePort filter |
Which channels to inspect | Narrow, misses odd ports | All ports, more noise |
| Jitter handling | Tolerate randomised intervals | Miss modern beacons | False positives from bursty apps |
Lateral movement
Lateral movement shows as authentication to hosts an account has never touched, especially via network (type 3), RDP (type 10), or alternate credentials (type 9). The “new source→destination pair” pattern is the workhorse — build a baseline of seen pairs and hunt the residual:
// Lateral movement: new (source-account, destination-host) RDP/network pairs (T1021)
let lookback = 1d;
let baselineWindow = 14d;
let seen = SecurityEvent
| where TimeGenerated between (ago(baselineWindow) .. ago(lookback))
| where EventID == 4624 and LogonType in (3, 9, 10)
| distinct Account, Computer;
SecurityEvent
| where TimeGenerated > ago(lookback)
| where EventID == 4624 and LogonType in (3, 9, 10)
| where Account !endswith "$" // exclude machine accounts
| summarize LogonCount = count(),
LogonTypes = make_set(LogonType),
SrcIPs = make_set(IpAddress, 5)
by Account, Computer
| join kind=leftanti seen on Account, Computer // keep only pairs never seen before
| order by LogonCount desc
The lateral-movement signals worth standing up, and where each lives:
| Signal | Technique | Table + filter | Note |
|---|---|---|---|
| New RDP source→dest | T1021.001 | SecurityEvent 4624 LogonType 10 |
Highest-fidelity for admins |
| SMB/admin-share access | T1021.002 | SecurityEvent 5140/5145 |
Watch ADMIN$, C$ |
| WinRM/PSRemoting | T1021.006 | SecurityEvent 4624 type 3 + wsmprovhost.exe |
Pair with process events |
| Alt-credential logon | T1550 | SecurityEvent 4648 / 4624 type 9 |
runas /netonly pattern |
| Pass-the-hash | T1550.002 | SecurityEvent 4624 type 3, NTLM, no 4776 chain |
Hard; corroborate |
| Endpoint-to-endpoint (MDE) | T1021 | DeviceLogonEvents remote logons |
Cross-check with SecurityEvent |
Credential access
Credential-access hunts are among the most valuable because they sit early in the kill chain. Kerberoasting (T1558.003) requests service tickets with weak RC4 encryption to crack offline:
// Kerberoasting: RC4 service tickets for many SPNs from one account (T1558.003)
let lookback = 1d;
SecurityEvent
| where TimeGenerated > ago(lookback)
| where EventID == 4769
| where TicketEncryptionType == "0x17" // RC4-HMAC — weak, crackable
| where ServiceName !endswith "$" // exclude machine-account services
| where TicketOptions == "0x40810000"
| summarize DistinctSPNs = dcount(ServiceName),
RequestCount = count(),
Services = make_set(ServiceName, 10)
by Account, ClientAddress = tostring(IpAddress)
| where DistinctSPNs >= 5 // one account, many SPNs = roasting
| order by DistinctSPNs desc
DCSync (T1003.006) abuses directory replication to pull password hashes — the tell is a non-domain-controller account requesting the replication access right:
// DCSync: replication rights requested by a non-DC account (T1003.006)
SecurityEvent
| where TimeGenerated > ago(1d)
| where EventID == 4662 and AccessMask == "0x100"
| where Properties has "1131f6ad-9c07-11d1-f79f-00c04fc2dcd2" // DS-Replication-Get-Changes-All
or Properties has "1131f6aa-9c07-11d1-f79f-00c04fc2dcd2" // DS-Replication-Get-Changes
| where Account !endswith "$" // legit DCs are machine accounts
| project TimeGenerated, Account, ObjectName, SubjectLogonId, Computer
The credential-access catalogue, so you can build the backlog:
| Technique | ID | Signal | Table |
|---|---|---|---|
| Password spraying | T1110.003 | Many accounts / few tries / one IP; ResultType 50126 |
SigninLogs |
| Kerberoasting | T1558.003 | 4769 RC4, many SPNs | SecurityEvent |
| AS-REP roasting | T1558.004 | 4768 with no pre-auth | SecurityEvent |
| DCSync | T1003.006 | 4662 replication GUID, non-DC | SecurityEvent |
| LSASS memory access | T1003.001 | Handle to lsass.exe |
DeviceEvents |
| NTDS.dit access | T1003.003 | Access to ntds.dit / ntdsutil |
DeviceProcessEvents |
| MFA fatigue | T1621 | Repeated 50074/50088, one approval | SigninLogs |
| Token/cookie theft | T1550.004 | Non-interactive sign-in, new ASN, no prior device | AADNonInteractiveUserSignInLogs |
For LSASS access, MDE surfaces processes opening a handle to the credential store:
// LSASS access: non-system processes opening a handle to lsass.exe (T1003.001)
DeviceEvents
| where Timestamp > ago(1d)
| where ActionType == "OpenProcessApiCall"
| where FileName =~ "lsass.exe"
| where InitiatingProcessFileName !in~ ("MsMpEng.exe","csrss.exe","wininit.exe","services.exe")
| project Timestamp, DeviceName, InitiatingProcessFileName,
InitiatingProcessCommandLine, InitiatingProcessAccountName
| order by Timestamp desc
Exfiltration
Exfil hunts split by channel: email, cloud storage, and raw network. The highest-fidelity cloud hunt is the auto-forwarding rule to an external address (T1114.003), because it is both a persistence and an exfil mechanism and almost never legitimate at scale:
// Exfil via mail forwarding rule to an external recipient (T1114.003)
let corpDomains = dynamic(["contoso.com","contoso.onmicrosoft.com"]);
OfficeActivity
| where TimeGenerated > ago(7d)
| where OfficeWorkload == "Exchange"
| where Operation in ("New-InboxRule","Set-InboxRule","UpdateInboxRules")
| mv-apply p = parse_json(Parameters) on (
where p.Name in ("ForwardTo","RedirectTo","ForwardAsAttachmentTo")
| project ForwardDest = tostring(p.Value)
)
| where isnotempty(ForwardDest)
| extend ExternalDomain = tolower(extract(@"@([\w.\-]+)", 1, ForwardDest))
| where isnotempty(ExternalDomain) and ExternalDomain !in (corpDomains)
| project TimeGenerated, UserId, ClientIP, Operation, ForwardDest, ExternalDomain
| order by TimeGenerated desc
The exfiltration signals and their channels:
| Channel | Technique | Signal | Table |
|---|---|---|---|
| Mail forwarding rule | T1114.003 | New-InboxRule → external |
OfficeActivity |
| Mass mailbox access | T1114 | MailItemsAccessed burst |
OfficeActivity |
| Mass file download | T1567 | FileDownloaded/FileSyncDownloadedFull spike |
OfficeActivity |
| Anonymous share link | T1567.002 | AnonymousLinkCreated |
OfficeActivity |
| Unsanctioned cloud upload | T1567.002 | FileUploaded to risky app |
CloudAppEvents |
| Large outbound transfer | T1048 | High egress bytes to new dest | DeviceNetworkEvents |
| DNS tunneling | T1048.003 / T1071.004 | High-entropy/long DNS queries | DeviceNetworkEvents |
UEBA: BehaviorAnalytics, anomalies, and entity pages
Sentinel’s UEBA (User and Entity Behavior Analytics) does the per-entity baselining for you and writes it to queryable tables. Enable it under Sentinel → Settings → Entity behavior (it needs the relevant data sources connected — SigninLogs, AuditLogs, SecurityEvent, AzureActivity, OfficeActivity). The tables you hunt against:
| Table | What it gives you |
|---|---|
BehaviorAnalytics |
Per-activity enrichment with InvestigationPriority (0–10), “first time” flags, peer/blast-radius context |
IdentityInfo |
Identity attributes: group membership, manager, account state, on-prem SID, department |
UserPeerAnalytics |
Peer ranking — who this user behaves like, for peer-outlier hunts |
UserAccessAnalytics |
Effective access / blast radius derived from role and group data |
The single most useful field is InvestigationPriority. It folds dozens of behavioural signals into one score, so a high-signal account-takeover (ATO) or insider hunt is often just “rank activities by priority, filter to the unusual ones”:
// ATO / insider hunt: high-priority anomalous activity + identity context
BehaviorAnalytics
| where TimeGenerated > ago(7d)
| where InvestigationPriority > 5
| extend FirstTimeFromCountry = tobool(ActivityInsights.FirstTimeUserConnectedFromCountry)
| extend UncommonForUser = tobool(ActivityInsights.ActionUncommonlyPerformedByUser)
| extend FirstTimeFromDevice = tobool(ActivityInsights.FirstTimeUserLoggedOnToDevice)
| where FirstTimeFromCountry or UncommonForUser or FirstTimeFromDevice
| join kind=leftouter (
IdentityInfo
| summarize arg_max(TimeGenerated, *) by AccountUPN
| project AccountUPN, GroupMembership, IsAccountEnabled, Manager, JobTitle
) on $left.UserPrincipalName == $right.AccountUPN
| project TimeGenerated, UserName, ActivityType, SourceIPAddress,
InvestigationPriority, FirstTimeFromCountry, UncommonForUser,
FirstTimeFromDevice, GroupMembership, Manager, JobTitle
| sort by InvestigationPriority desc
The ActivityInsights dynamic column is where the behavioural flags live — the ones you branch on most:
ActivityInsights flag |
Fires when… | Hunt use |
|---|---|---|
FirstTimeUserConnectedFromCountry |
User’s first sign-in from this country | ATO / impossible travel |
FirstTimeConnectionFromCountryObservedInTenant |
First time anyone connected from here | Novel-geo campaign |
ActionUncommonlyPerformedByUser |
This action is rare for this user | Insider / peer-outlier |
ActionUncommonlyPerformedInTenant |
Rare across the whole org | Novel technique |
FirstTimeUserLoggedOnToDevice |
New device for this user | Lateral movement / ATO |
FirstTimeUserAccessedResource |
New resource for this user | Discovery / collection |
UncommonHighVolumeOfActivities |
Volume spike vs baseline | Exfil / automation |
How to read InvestigationPriority, because a raw number needs a policy:
| Score band | Interpretation | Typical action |
|---|---|---|
| 0–2 | Routine, well within baseline | Ignore in bulk hunts |
| 3–5 | Mild deviation | Sample; watch for clusters |
| 6–8 | Notable anomaly | Triage the entity page |
| 9–10 | Strong multi-signal anomaly | Bookmark; consider incident |
For insider risk, invert the lens: instead of impossible travel, hunt for legitimate access that is anomalous for the peer group — a finance analyst suddenly enumerating HR shares. UserPeerAnalytics supplies the peer set; the deviation is the signal. And use the entity pages (Sentinel’s timeline view for a user/host/IP) to pivot: from any BehaviorAnalytics row, open the entity to see its full activity timeline, insights, and related alerts in one pane — the fastest way to adjudicate a lead as benign or worth an incident.
Hunting notebooks: Jupyter and MSTICPy
Notebooks are where a hunt stops being a single query and becomes an investigation. MSTICPy (Microsoft Threat Intelligence Center Python library) wraps Sentinel/Defender queries, entity enrichment (VirusTotal, GeoIP, threat intel), and visualization behind a clean API. Sentinel can launch notebooks on an Azure ML compute instance, or you run them locally against the same workspace.
The MSTICPy components you actually use:
| Component | Purpose |
|---|---|
init_notebook() |
One-call bootstrap: loads config, query/TI providers, plotting |
QueryProvider |
Connect to a data backend (MSSentinel, MDE, Kusto, Splunk, LocalData) and run KQL |
TILookup |
Multi-provider threat-intel lookups (VirusTotal, AlienVault OTX, etc.) |
GeoLiteLookup / IPStackLookup |
IP geolocation enrichment |
Pivot |
Attach enrichment/query functions to entity types (Host, IpAddress, Account) |
nbwidgets |
Interactive parameter widgets (time ranges, entity pickers) |
nbdisplay |
Timelines, process trees, network graphs |
IoCExtract / transform |
Pull IOCs (IPs, hashes, domains) out of free text; decode base64 |
MpConfigEdit |
GUI editor for msticpyconfig.yaml |
Configuration lives in msticpyconfig.yaml (point to it with the MSTICPYCONFIG env var) so connection strings and API keys never land in cells:
# msticpyconfig.yaml
AzureSentinel:
Workspaces:
Default:
WorkspaceId: "00000000-1111-2222-3333-444444444444"
TenantId: "55555555-6666-7777-8888-999999999999"
TIProviders:
VirusTotal:
Args:
AuthKey:
EnvironmentVar: "VT_AUTH"
Primary: true
Provider: "VirusTotal"
A parameterized hunt notebook then reads like this — parameterizing the entity and lookback is what lets one notebook serve every “investigate this user” hunt:
import msticpy as mp
mp.init_notebook(globals()) # loads config, query providers, helpers
# --- parameters cell (tag this cell "parameters" for headless/Papermill runs) ---
target_user = "j.doe@contoso.com"
lookback_days = 14
qry_prov = mp.QueryProvider("MSSentinel")
qry_prov.connect(workspace="Default") # uses msticpyconfig.yaml
signins = qry_prov.exec_query(f"""
SigninLogs
| where TimeGenerated > ago({lookback_days}d)
| where UserPrincipalName == '{target_user}'
| project TimeGenerated, IPAddress, AppDisplayName, ResultType, Location
""")
# enrich distinct source IPs against threat intel, then plot a timeline
ti = mp.TILookup()
ti_results = ti.lookup_iocs(data=signins, ioc_col="IPAddress")
mp.nbdisplay.display_timeline(signins, source_columns=["AppDisplayName", "Location"])
When to reach for a notebook versus staying in the portal:
| Situation | Portal KQL | MSTICPy notebook |
|---|---|---|
| Quick single-table query | ✅ Fastest | Overkill |
| Multi-source enrichment (TI + geo + WHOIS) | ✖ Manual | ✅ One pipeline |
| Iterative deep-dive on one entity | ✖ Loses state | ✅ Cells hold state |
| Reproducible, reviewed, versioned hunt | ✖ Ad hoc | ✅ Commit the .ipynb |
| Scheduled/headless run | Use analytics rule | ✅ Papermill on Azure ML |
| Statistical/ML modeling (M-ATH) | ✖ Limited | ✅ Full Python stack |
The discipline that makes notebooks pay off: keep the parameter cell at the top and tag it (Papermill injects parameters into a tagged cell for headless runs), and commit the .ipynb to the same Git repo as your KQL. A hunting notebook is code — it gets reviewed and versioned like code, and Microsoft ships a large gallery of starter notebooks you fork rather than write from zero.
From hunt to detection: bookmarks, livestream, and promotion
A hunt that found something must leave a durable trace, or the next hunter repeats your work. The exits, in increasing permanence:
| Exit | What it is | Persistence | When to use |
|---|---|---|---|
| Bookmark | A saved result row + notes + entity mappings | HuntingBookmark table |
Evidence of a specific finding |
| Hunt record | The Hunts object: hypothesis, queries, status, findings | Sentinel Hunts | Track the whole hunt lifecycle |
| Livestream | Near-real-time run of a candidate query | Ephemeral, notifies on hit | Validate a query before it’s a rule |
| Analytics rule | Scheduled/NRT query that raises incidents | Standing detection | A hunt that proved reliable |
| Defender custom detection | Advanced-hunting query as a standing rule | Standing detection | Endpoint/identity/email hunts |
| Watchlist | Externalised tuning data (VIPs, known-good) | Watchlist |
Reference data queries share |
Bookmarks capture an interesting query-result row with your notes and entity mappings — the unit of evidence. From a hunting-query result, select rows and “Add bookmark”; in the Hunts experience, bookmarks attach to the hunt itself and can be promoted to an incident when they warrant triage. They land in the HuntingBookmark table, so you can even hunt over your own bookmarks.
Livestream is the validation step people skip. Before you promote a candidate query to a rule, run it as a hunting livestream session: Sentinel executes it against incoming data in near-real-time and notifies you on each hit, so you see the real-world hit rate and false-positive shape over hours before committing it to a standing rule. It is the cheapest way to answer “will this rule be noisy?”
Promotion turns a validated hunt into a standing detection. In Sentinel that is a scheduled or NRT analytics rule (section examples above); in the Defender portal it is a custom detection rule built from an advanced-hunting query. The Sentinel rule types you promote into:
| Rule type | Cadence | Best for |
|---|---|---|
| Scheduled | Every 5 min – 14 days | Most hunts; full KQL, aggregation, thresholds |
| NRT (Near-Real-Time) | ~Every 1 min | High-urgency single-event detections |
| Microsoft Security | On source alert | Re-emit/route Defender alerts as incidents |
| Anomaly | Continuous (ML) | Built-in behavioural baselines |
| Fusion | Continuous (ML) | Multi-stage attack correlation |
| Threat Intelligence | Scheduled | Match logs against TI indicators |
Defender custom detections have their own cadence menu and a hard requirement: the query must return the right columns to bind evidence:
| Defender detection frequency | Runs | Notes |
|---|---|---|
| Continuous (NRT) | Near real-time | Subset of tables; single-event style |
| Every 1 hour | Hourly, looks back 2h | Common default |
| Every 3 hours | Looks back 6h | Balance freshness/cost |
| Every 12 hours | Looks back 24h | Lower-urgency behaviours |
| Every 24 hours | Looks back 30d window | Slow campaigns |
Not every hunt should become a rule. The promotion readiness checklist — a hunt graduates only when it clears every row:
| Gate | Question | Pass criterion |
|---|---|---|
| Fidelity | What’s the false-positive rate? | Low + explainable (livestream-measured) |
| Actionability | Is there a clear response? | A named next step for the analyst |
| Entity mapping | Can the incident be investigated? | Entities mapped (Account/Host/IP) |
| ATT&CK tagging | Is it tagged? | Tactics + technique(s) set |
| Ownership | Who tunes it? | A named owner / team |
| Suppression | Is known-good excluded? | Watchlist or let allow-list wired in |
The tuning data that keeps promoted rules quiet belongs in watchlists, not hardcoded let blocks scattered across queries — externalise VIP accounts, sanctioned admin IPs, known-good service principals, and terminated employees so you update one list, not twenty rules:
// Reference a watchlist of sanctioned admin source IPs to suppress known-good
let SanctionedAdminIPs = _GetWatchlist('AdminJumpHosts') | project SearchKey;
SigninLogs
| where TimeGenerated > ago(1d)
| where AppDisplayName == "Azure Active Directory PowerShell"
| where IPAddress !in (SanctionedAdminIPs) // hunt the residual
| project TimeGenerated, UserPrincipalName, IPAddress, ResultType
# Create the watchlist from a CSV so it lives in source control and deploys with the workspace
az sentinel watchlist create \
--resource-group rg-sec-sentinel --workspace-name law-sentinel-prod \
--watchlist-alias AdminJumpHosts --display-name "Sanctioned admin jump hosts" \
--provider "SOC" --source "admin_jump_hosts.csv" --items-search-key "SearchKey"
Hunting at scale: summary rules, table tiers, and ADX
The high-value hunts — beaconing, rare-process, DNS analysis — run over the highest-volume tables (network, DNS, proxy), which are exactly the tables you cannot afford to keep in the premium Analytics tier at full retention. Scaling a hunting program is a data-economics problem as much as a KQL problem. Log Analytics/Sentinel gives you three table tiers to place data in:
| Tier | Ingestion cost | Interactive retention | KQL support | Alerts | Use for |
|---|---|---|---|---|---|
| Analytics | Full price | 30 days free, up to 730 | Full KQL, joins, functions | ✅ Rules + hunts | Security-critical, correlated tables |
| Basic | ~Reduced (fraction of Analytics) | Up to 30 days | Limited (single-table, subset) | Limited | High-volume, occasionally queried |
| Auxiliary | Lowest | 30 days interactive + long-term | Limited; summary rules OK | Summary-rule driven | Verbose logs, compliance retention |
Beyond interactive retention, data rolls into long-term retention (up to 12 years total) where you reach it via search jobs and restore — cheap to store, slow to query, right for “we need it if there’s a breach” logs.
Summary rules are the scale primitive: a scheduled KQL aggregation that reads a high-volume (often Basic/Auxiliary) table and writes a compact result to a custom _CL table you then hunt and alert on cheaply. You keep the raw firehose in a cheap tier and hunt the pre-aggregated summary:
// Summary-rule body: hourly per-(device, remote IP) connection profile from raw network logs.
// Output goes to a custom table (e.g. NetworkBeaconSummary_CL) you hunt cheaply.
DeviceNetworkEvents
| where ActionType == "ConnectionSuccess"
| summarize ConnCount = count(),
DistinctPorts = dcount(RemotePort),
BytesOut = sum(coalesce(toint(column_ifexists("SentBytes", 0)), 0))
by DeviceId, RemoteIP, InitiatingProcessFileName, bin(TimeGenerated, 1h)
Azure Data Explorer (ADX) is the other half of scale. For very long retention or truly massive volumes, land data in an ADX cluster and query it from Sentinel with the cross-service adx() operator, or join Sentinel and ADX data in one query — you get ADX’s cost/perf for cold data while keeping one hunting surface:
// Cross-service query: join hot Sentinel sign-ins to cold auth history in ADX
let ColdAuth = adx('https://seccluster.eastus.kusto.windows.net/secdb').AuthArchive;
SigninLogs
| where TimeGenerated > ago(1d) and ResultType == 0
| join kind=leftouter (
ColdAuth | where Timestamp > ago(365d) | summarize KnownIPs = make_set(IPAddress) by UserPrincipalName
) on UserPrincipalName
| where IPAddress !in (KnownIPs) // sign-in from an IP unseen in a year of history
The scale mechanisms compared, so you place data deliberately:
| Mechanism | What it does | Cost profile | Query experience | Best for |
|---|---|---|---|---|
| Analytics tier | Full interactive analytics | Highest ingest | Full KQL, instant | Correlated, alertable security tables |
| Basic tier | Cheap high-volume storage | Low ingest, pay-per-query | Limited KQL | Firewall/proxy/verbose logs |
| Auxiliary tier | Cheapest, long retention | Lowest ingest | Limited + summary rules | Compliance + occasional hunt |
| Summary rules | Pre-aggregate raw → compact table | Query-time savings | Full KQL on the summary | Beaconing/prevalence at scale |
| ADX cross-query | Query external Kusto from Sentinel | ADX cluster cost | Full KQL via adx() |
Multi-year retention, huge volume |
| Long-term retention | Archive within Log Analytics | Cheap storage | Search jobs / restore | “Need it only in a breach” logs |
Architecture at a glance
Picture the hunting program as four planes stacked from raw telemetry at the bottom to durable detections at the top, with the hunter working across all four.
The data plane at the base is the telemetry lake: Windows security logs land in SecurityEvent via the Azure Monitor Agent and a Data Collection Rule; Entra sign-ins and directory changes flow into SigninLogs, AADNonInteractiveUserSignInLogs, and AuditLogs; Microsoft 365 audit lands in OfficeActivity; and the Defender store holds the Device*, Email*, Identity*, and Cloud* families at 30-day retention. High-volume tables (network, DNS, proxy) sit in the Basic or Auxiliary tiers or in an ADX cluster, with summary rules distilling them into compact _CL tables. Everything is reachable from one KQL surface — the unified advanced-hunting experience in the Defender portal spans both the Log Analytics and Defender stores.
The enrichment plane sits just above: UEBA reads the sign-in, audit, and security tables and writes baselined signal to BehaviorAnalytics, IdentityInfo, UserPeerAnalytics, and UserAccessAnalytics, folding behavioural features into a single InvestigationPriority. Watchlists supply known-good reference data, and threat-intel indicators arrive through the TI connector. This is the plane that answers “is this normal for this entity?” without the hunter scripting a baseline.
The analysis plane is where the hunter operates: portal KQL for quick tests, hunting queries saved with ATT&CK tags, livestream for near-real-time validation, and MSTICPy notebooks on Azure ML compute for multi-source deep dives. A hypothesis enters here as text, becomes parameterized KQL, and produces a residual the hunter adjudicates. Bookmarks capture evidence; the Hunts object tracks the hypothesis, linked queries, and status.
The detection plane at the top is where validated hunts become permanent: scheduled and NRT analytics rules in Sentinel, custom detection rules in Defender, each tagged with tactics and techniques so the MITRE ATT&CK coverage matrix shades honestly. The feedback arrow — from a promoted detection back to the next hypothesis and to refreshed baselines — is what closes the Sqrrl loop. Read the whole picture left to right and bottom to top: telemetry is enriched into baselined signal, the hunter tests a hypothesis against it, evidence is captured, and what proves reliable is promoted into a standing detection whose coverage is measured as effective, not merely intended.
Real-world scenario
Meridian Assurance, a global insurer, ran a Sentinel workspace ingesting ~220 GB/day across endpoint, sign-in, and Microsoft 365 audit, with ~140 analytics rules tagged across 60+ ATT&CK techniques. The MITRE matrix the CISO showed the board was reassuringly green. Then the quarterly purple-team exercise walked an OAuth consent-grant attack (T1528) — a malicious multi-tenant app that phished a finance manager into granting Mail.Read and Files.Read.All — straight through undetected, and used it to read the CFO’s mailbox for nine days before the red team self-reported.
The post-exercise review found the real defect was not a missing rule but a broken measurement. Coverage was tracked as “a rule exists.” Several “covered” techniques were backed by rules disabled months earlier during a noise-reduction sprint, and nobody had updated the matrix. T1528 had a tagged rule; it had been switched off in the cleanup because it fired on legitimate admin consents, and the amber never surfaced because the board metric only counted tags.
The hunt team fixed the measurement first. A scheduled MSTICPy notebook (Papermill, nightly on an Azure ML compute) pulled every rule via the alertRules API, joined enabled state and 90-day fire counts from SecurityAlert, and emitted a coverage report distinguishing intended from effective coverage. Techniques with a rule but zero useful fires in 90 days were flagged amber, not green. The amber list — 23 techniques — became the hunt backlog.
For T1528 specifically they ran a hypothesis-driven hunt: “If an attacker phished a user into consenting to a malicious app, I will see a Consent to application event in AuditLogs for an app that is newly registered, multi-tenant, and requests high-value Graph scopes, from a user who is not an admin.” The query joined AuditLogs consent events to app metadata and filtered to first-seen apps requesting Mail.Read, Mail.ReadWrite, Files.Read.All, or full_access_as_app. It surfaced the purple-team app immediately — and two real risky grants nobody had noticed: a browser extension with offline_access and a defunct vendor integration still holding Sites.FullControl.All.
They validated the query on livestream for 48 hours (three benign hits, all sanctioned admin consents, which they added to a SanctionedConsentApps watchlist), then promoted it to a scheduled analytics rule tagged Persistence / T1528, with the watchlist suppressing known-good. They also wired the consent surface reduction from Governing OAuth Consent and Application Permissions in Entra ID — admin-consent workflow plus restricted user consent — so the attack surface shrank while the detection went live.
Six weeks later the board metric changed from “techniques covered” to “techniques covered and validated in the last quarter.” The matrix had fewer green cells — 44, down from a claimed 60+ — but every green cell was backed by a rule that had fired and been triaged at least once. The incident-to-hunt timeline told the lesson in order:
| Phase | What happened | Effect | The lesson |
|---|---|---|---|
| Purple team | T1528 consent grant walks through | 9-day undetected mailbox read | Green ≠ detecting |
| Review | Found rule existed but was disabled | Coverage was theatre | Measure effective, not intended |
| Measurement fix | Nightly notebook: intended vs effective | 23 amber techniques surfaced | Automation makes coverage honest |
| Hypothesis hunt | Consent-grant KQL over AuditLogs |
Found purple app + 2 real grants | Behaviour beats IOC |
| Validation | 48h livestream, 3 benign hits | Tuned suppression before promotion | Livestream sizes the noise |
| Promotion | Scheduled rule + watchlist + ATT&CK tag | T1528 truly covered | The hunt’s exit is a detection |
| Board metric | “Covered and validated last quarter” | Fewer green cells, all honest | The only number worth reporting |
Advantages and disadvantages
Structured, ATT&CK-anchored hunting on Sentinel/Defender both finds what detections miss and risks turning into an expensive, noisy time sink if run without discipline. Weigh it honestly:
| Advantages (why this approach wins) | Disadvantages (where it bites) |
|---|---|
| Finds behaviour your standing detections never anticipated — closes the dwell-time gap | Requires skilled analysts; a bad hunt produces false confidence, not coverage |
| ATT&CK tagging turns hunts into a measurable coverage map | The matrix measures intent; a green cell can be a lie without effective-coverage tracking |
| UEBA does per-entity baselining for you — anomalies in one field | UEBA needs the right sources connected and a warm-up period; empty BehaviorAnalytics misleads |
| One KQL surface (unified advanced hunting) spans endpoint, identity, email, cloud | High-volume tables are expensive at Analytics tier; naive queries scan TB and cost money |
| Notebooks make hunts reproducible, reviewable, versioned code | Notebook infra (Azure ML compute, MSTICPy config, secrets) is real setup overhead |
| Livestream sizes a rule’s noise before you promote it | Skipping validation ships noisy rules that erode analyst trust in the whole program |
| Successful hunts graduate into standing detections — the loop compounds | Without a cadence + peer review, hunts stay one-shot and knowledge evaporates |
| Summary rules + Auxiliary tier + ADX make scale affordable | Data-tier placement is a design decision; wrong tier = either blind or broke |
The approach is right for any SOC past HMM1 that has the telemetry and wants to convert it into proactive coverage. It bites hardest on teams that hunt without a hypothesis (motion without progress), track coverage without measuring efficacy (theatre), or hunt high-volume tables at Analytics tier without summary rules (a surprising bill). Every disadvantage is manageable — but only if you know it exists, which is the point of the methodology sections above.
Hands-on lab
Stand up the hunting workflow end to end on an existing Sentinel workspace: run a hypothesis hunt, bookmark a finding, tag it to ATT&CK, promote it to a rule via IaC, and build a summary rule for scale. Run in Cloud Shell (Bash) unless noted. This assumes a Sentinel-enabled Log Analytics workspace with SigninLogs and AuditLogs flowing; if you don’t have one, How to Create a Log Analytics Workspace covers the setup and the Sentinel solution is a one-click add.
Step 1 — Set variables and confirm the workspace.
RG=rg-sec-sentinel
WS=law-sentinel-prod
SUB=$(az account show --query id -o tsv)
az monitor log-analytics workspace show -g $RG -n $WS --query "{name:name, retention:retentionInDays}" -o table
Expected: the workspace row with its retention. If it errors, fix the names before continuing.
Step 2 — Run a hypothesis hunt (spraying, T1110.003) and confirm it returns a usable shape. Paste into Logs (portal) or run headless:
SigninLogs
| where TimeGenerated > ago(1d) and ResultType == 50126
| summarize TargetedAccounts = dcount(UserPrincipalName), Attempts = count()
by IPAddress, AppDisplayName, bin(TimeGenerated, 1h)
| where TargetedAccounts >= 10 and Attempts >= 15
| order by TargetedAccounts desc
Expected on a real tenant: zero-to-few rows. Zero is a valid negative result — the hunt is falsifiable. A row with high TargetedAccounts from one IP is a spraying lead.
Step 3 — Verify UEBA is populating (needed for the ATO hunt).
BehaviorAnalytics | where TimeGenerated > ago(1d) | summarize Rows = count()
Expected: a non-zero count. Zero after 24h+ means UEBA is off or its source tables aren’t connected — enable it under Settings → Entity behavior and wait for the baseline to warm.
Step 4 — Create a bookmark from a finding (portal). In Hunting → Queries, run the Step 2 query, select an interesting row, choose Add bookmark, map the IPAddress column to the IP entity and UserPrincipalName to Account, add a note (“T1110.003 spraying candidate”), and save. Confirm it landed:
HuntingBookmark
| where TimeGenerated > ago(1d)
| project TimeGenerated, BookmarkName, Tags, CreatedBy, QueryResultRow
Expected: your bookmark row.
Step 5 — Promote the validated query to a scheduled analytics rule (Bicep). Save this as spray-rule.bicep and the KQL as queries/t1110-spray.kql, then deploy:
param workspaceName string
resource ws 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = { name: workspaceName }
resource sprayRule 'Microsoft.SecurityInsights/alertRules@2024-03-01' = {
scope: ws
name: guid('hunt-T1110-spray')
kind: 'Scheduled'
properties: {
displayName: 'Password spraying — many accounts, one IP (T1110.003)'
severity: 'Medium'
enabled: true
query: loadTextContent('queries/t1110-spray.kql')
queryFrequency: 'PT1H'
queryPeriod: 'P1D'
triggerOperator: 'GreaterThan'
triggerThreshold: 0
tactics: [ 'CredentialAccess' ]
techniques: [ 'T1110' ]
subTechniques: [ 'T1110.003' ]
entityMappings: [
{ entityType: 'IP', fieldMappings: [ { identifier: 'Address', columnName: 'IPAddress' } ] }
]
}
}
az deployment group create -g $RG --template-file spray-rule.bicep \
--parameters workspaceName=$WS -o table
Expected: a successful deployment; the rule appears under Analytics → Active rules tagged to T1110.003.
Step 6 — Confirm the technique now shows in coverage.
az rest --method get \
--url "https://management.azure.com/subscriptions/$SUB/resourceGroups/$RG/providers/Microsoft.OperationalInsights/workspaces/$WS/providers/Microsoft.SecurityInsights/alertRules?api-version=2024-03-01" \
--query "value[?properties.enabled].properties.techniques" -o tsv | sort -u
Expected: T1110 appears in the list — intended coverage is now real and IaC-tracked.
Step 7 — Create a summary rule for scale (portal or ARM). In Sentinel → Summary rules → Create, point it at a high-volume source with the aggregation from the scale section, set an hourly bin, and target a new NetworkBeaconSummary_CL table. Confirm it produces rows after the first run window.
Validation checklist. You ran a falsifiable hunt, confirmed UEBA is live, captured evidence as a bookmark with entity mappings, promoted the query to an ATT&CK-tagged rule as code, verified the coverage moved, and built a summary rule for scale. What each step proved:
| Step | What you did | What it proves |
|---|---|---|
| 2 | Ran the spraying hypothesis | Hunts are falsifiable; a negative is a result |
| 3 | Checked BehaviorAnalytics |
UEBA readiness gates ATO hunts |
| 4 | Bookmarked with entity mappings | Findings become durable evidence |
| 5 | Promoted via Bicep | Detections are code, reviewed and versioned |
| 6 | Verified coverage | Intended coverage is real and tracked |
| 7 | Built a summary rule | Scale is a data-tier design choice |
Teardown. Remove only what the lab created (leave the workspace):
az deployment group delete -g $RG -n spray-rule 2>/dev/null
# In the portal: delete the analytics rule, the bookmark, and the summary rule if they were test-only.
Cost note. Running these queries is pennies (query cost on Analytics tier is included; Basic/Auxiliary queries bill per-GB scanned). The scheduled rule adds negligible cost. The only real spend is ingestion, which you already pay — the lab adds no new data.
Common mistakes & troubleshooting
The failure modes that waste the most hunting time, as a scannable playbook first, then the reasoning:
| # | Symptom | Root cause | Confirm (exact query / path) | Fix |
|---|---|---|---|---|
| 1 | series_decompose_anomalies returns garbage / mismatched rows |
Not mv-expand-ing all returned series in lockstep |
Compare column lengths after decompose | mv-expand anomalies, score, baseline together |
| 2 | Anomaly query misses obvious spikes | make-series without default=0 / bounded range |
Inspect the series for gaps | Add default=0 ... from ago(N) to now() |
| 3 | BehaviorAnalytics empty after enabling UEBA |
Source tables not connected or still warming | BehaviorAnalytics | count = 0 after 24h |
Connect SigninLogs/SecurityEvent; wait for baseline |
| 4 | Beaconing hunt floods with browser noise | No process/port allow-list | Top results are chrome.exe/msedge.exe |
Exclude known chatty processes; tune maxCoV |
| 5 | Rare-process hunt returns thousands of rows | Threshold too high; no path exclusions | Result count in the thousands | Lower rareThreshold; exclude standard paths; leftanti baseline |
| 6 | Kerberoasting hunt finds nothing on a real DC | 4769 not audited / RC4 disabled | SecurityEvent | where EventID==4769 | count = 0 |
Enable Kerberos audit; note AES-only tenants |
| 7 | subTechniques silently missing on a deployed rule |
Old alertRules API version |
Rule JSON lacks subTechniques |
Pin API ≥ 2023-02-01 |
| 8 | ATT&CK matrix green but breach walked through | Tracking intended, not effective, coverage | SecurityAlert fire count = 0 for the technique |
Add effective-coverage join; flag amber |
| 9 | Cross-portal query fails: “table not found” | Table lives in the other store | Query the wrong portal | Use unified advanced hunting; check the Rosetta table |
| 10 | Promoted rule is extremely noisy | Skipped livestream validation | Incident queue floods post-promotion | Validate on livestream first; wire a watchlist suppression |
| 11 | Notebook auth fails / secrets in cells | No msticpyconfig.yaml / env-var secrets |
init_notebook errors on connect |
Point MSTICPYCONFIG; use EnvironmentVar for keys |
| 12 | High-volume hunt is slow and expensive | Querying raw firehose at Analytics tier | Query scans TB; long runtime | Summary rule → compact table; move raw to Basic/Auxiliary |
| 13 | OfficeActivity forwarding hunt misses rules |
Parameters not parsed / wrong operation names |
mv-apply returns nothing |
Parse Parameters JSON; include all three rule ops |
| 14 | Entity mapping wrong; incident graph empty | Wrong identifier for the entity type |
Investigation graph has no nodes | Use correct identifier (Account=FullName, IP=Address) |
The entries that bite hardest, expanded:
1. Mismatched mv-expand after decomposition. series_decompose_anomalies() returns three series (anomalies, score, baseline). If you mv-expand only one, the others don’t flatten in lockstep and rows misalign. Confirm: the flattened score doesn’t correspond to its TimeGenerated. Fix: expand all of them plus the axis together — mv-expand TimeGenerated to typeof(datetime), series to typeof(long), anomalies to typeof(int), score to typeof(double).
8. Intended vs effective coverage. The single most expensive hunting-program mistake. The matrix shades on tags, so a disabled or never-firing rule still paints a cell green and the board believes it’s covered. Confirm: run the SecurityAlert fire-count join from the ATT&CK section; techniques with zero 90-day fires are your blind spots. Fix: track effective coverage nightly and treat “tagged but silent” as amber, not green.
10. Promoting without livestream. A query that looks clean on a day of historical data can be noisy against live traffic (a nightly batch job, a monitoring probe). Confirm: the incident queue floods right after you enable the rule. Fix: run the candidate as a livestream session for 24–48h first, size the false positives, and add a watchlist-based suppression before you promote.
12. Hunting the firehose at premium tier. Beaconing/DNS/proxy hunts over raw Analytics-tier tables scan terabytes and cost real money per run. Confirm: query runtime in tens of seconds and a scan volume in TB. Fix: pre-aggregate with a summary rule into a compact _CL table, keep the raw data in the Auxiliary tier, and hunt the summary; reach for ADX for multi-year retention.
Best practices
- Write the hypothesis before the KQL. Every hunt starts as a falsifiable, ATT&CK-tagged sentence naming the table, the signal, and what a negative result looks like. No hypothesis, no hunt.
- Parameterize with
let; project a stable schema. Put lookbacks, thresholds, and allow-lists inletblocks at the top, and always emit the same column names so bookmarking, notebooks, and entity mapping are consistent. - Bound every time-series.
make-seriesgetsdefault=0and an explicitfrom/to;series_decompose_anomaliesoutputs aremv-expand-ed in lockstep. - Hunt behaviour, not indicators. Target the top of the Pyramid of Pain — TTPs — so hunts survive the adversary rotating hashes and IPs.
- Track effective coverage. Measure “fired and triaged in 90 days,” not “a rule exists.” Flag tagged-but-silent techniques amber and put them on the backlog.
- Validate on livestream before promotion. Size a candidate rule’s noise against live data for 24–48h; promote only what clears the readiness checklist.
- Externalise tuning to watchlists. VIPs, sanctioned IPs, known-good apps, and terminated users live in watchlists referenced by
_GetWatchlist, not hardcoded across queries. - Treat detections and notebooks as code. Analytics rules in Bicep/ARM, hunting queries and
.ipynbnotebooks in Git, reviewed and versioned. - Run a cadence with peer review. 2–3 hypothesis hunts weekly from a prioritised backlog; a second hunter reviews query logic and conclusion before a hunt becomes a detection.
- Document refuted hypotheses. “We hunted T1207 DCShadow over 90 days and found nothing, here is the query” is a real result — it tells the next hunter where the floor already is.
- Place data by tier deliberately. Security-critical correlated tables in Analytics; high-volume verbose logs in Basic/Auxiliary with summary rules; multi-year retention in ADX or long-term archive.
- Refresh baselines quarterly. UEBA and statistical baselines drift as the org changes; re-tune thresholds and retire detections that never fire usefully.
Security notes
Hunting is a privileged activity over sensitive data, so the hunting platform itself is an attack surface. Grant hunters Microsoft Sentinel Responder or Reader (and Log Analytics Reader) rather than Contributor — reading and bookmarking needs no write to the workspace; only rule authors need Microsoft Sentinel Contributor. Never hand hunters subscription Owner “to make it easier.” Notebook compute (Azure ML) should run under a managed identity with least-privilege access to the workspace, and MSTICPy secrets (VirusTotal keys, etc.) belong in environment variables or Key Vault, never in cells or committed config.
The queries themselves can leak: a bookmark or notebook output can contain live credentials, PII, or the exact detection logic an insider could evade. Treat hunt artifacts as sensitive, restrict who can read HuntingBookmark and exported notebooks, and audit access to the workspace. The RBAC and hardening reference:
| Concern | Control | Why |
|---|---|---|
| Hunter access | Sentinel Responder/Reader, not Contributor | Read + bookmark needs no workspace write |
| Rule authoring | Sentinel Contributor, scoped | Only rule authors change detections |
| Notebook compute | Managed identity, least privilege | No standing secrets on the box |
| TI/API keys | Env var or Key Vault | Keys out of cells and Git |
| Bookmark/notebook data | Restrict read; audit access | Outputs contain creds/PII/logic |
| Watchlist contents | Treat VIP/admin lists as sensitive | They map your crown jewels |
| Detection logic | Version control + review | An insider who reads it can evade it |
| Cross-tenant apps | Correlate consent hunts with governance | T1528 is the modern initial-access path |
Pair the hunting program with the identity controls that shrink the attack surface it hunts: risk-based Conditional Access from Operationalizing Entra ID Protection, consent hardening from Governing OAuth Consent and Application Permissions in Entra ID, and privileged-access controls from Privileged Identity Management and PAM Architecture. A hunt that finds a technique whose surface you could have removed is a hint to remove the surface, not just detect it.
Cost & sizing
The bill for a hunting program is dominated by data ingestion and retention, not by running queries. Sentinel charges a per-GB analysis fee on top of Log Analytics ingestion (with commitment tiers that discount at 100/200/300+ GB/day), while Defender XDR advanced-hunting data carries no separate ingestion charge and is retained 30 days. The cost drivers, ranked:
| Driver | What inflates it | How to control it |
|---|---|---|
| Analytics-tier ingestion | High-volume verbose logs at premium price | Move firewall/proxy/DNS to Basic/Auxiliary |
| Interactive retention | Long retention on every table | Retain only correlated tables long; archive the rest |
| Query scan (Basic/Aux) | Ad-hoc scans over huge tables | Summary rules; scan compact _CL tables |
| ADX cluster | Under-utilised cold-storage cluster | Right-size; autoscale; reserved capacity |
| Notebook compute | Idle Azure ML instances | Auto-shutdown; small SKUs; schedule runs |
| Redundant ingestion | Same signal from two connectors | De-duplicate connectors; prefer the free path |
The tier economics that make scale affordable (relative cost, not exact regional pricing — always price your own region):
| Data placement | Relative ingest cost | Relative query cost | Retention | Use for |
|---|---|---|---|---|
| Analytics tier | 1.0× (baseline) | Included | 30d free → 2y | Alertable, correlated security tables |
| Basic tier | ~0.2–0.3× | Per-GB scanned | Up to 30d + long-term | High-volume, occasionally queried |
| Auxiliary tier | ~0.05–0.1× | Per-GB scanned | 30d + up to 12y | Verbose logs, compliance retention |
Summary output (_CL) |
Analytics rate on tiny volume | Included | Your choice | Pre-aggregated hunt targets |
| ADX cluster | Cluster + storage | Included in cluster | Years | Massive volume, multi-year |
| Defender store | No extra ingest | Included | 30d fixed | Endpoint/identity/email/cloud hunts |
Sizing guidance: a mid-size SOC ingesting ~200 GB/day typically buys a 100 GB/day commitment tier and pushes verbose network/proxy/DNS into Auxiliary, cutting the effective per-GB cost of the noisy 60% of volume by roughly 5–10×. In INR terms, moving 60 GB/day of proxy logs from Analytics (~₹190–210/GB region-dependent) to Auxiliary (a small fraction) is the difference between a five-figure and a low-four-figure daily line item — the single highest-leverage cost decision in a hunting program. Notebook compute is negligible if you auto-shutdown; leaving an Azure ML instance running 24×7 is a common surprise on the bill.
Interview & exam questions
Q1. What makes a threat hunt different from running a detection? A detection is an automated, precise rule for a known pattern; a hunt is a human-led, hypothesis-driven search for behaviour your detections don’t yet catch, aimed at closing the dwell-time gap. A hunt’s output is knowledge — a refuted hypothesis or a new detection — not just an alert.
Q2. State a good hunt hypothesis and explain why it’s testable. “If an attacker Kerberoasted, I’ll see 4769 events with RC4 (0x17) tickets for many SPNs from one account in the last day.” It’s testable because it names the table (SecurityEvent), the exact signal (EventID 4769, TicketEncryptionType 0x17), the condition (many SPNs, one account), and an implicit negative (no such pattern → close the hunt).
Q3. What is the difference between intended and effective ATT&CK coverage? Intended coverage means a rule is tagged with a technique; effective coverage means that rule actually fired and was triaged in a window. The MITRE matrix shades on intent, so a disabled or never-firing rule paints a cell green — you must join SecurityAlert fire counts to measure efficacy.
Q4. Why must you mv-expand all outputs of series_decompose_anomalies together? The function returns three parallel series (anomalies, score, baseline) aligned to the time axis. Expanding only one leaves the others as arrays, so rows misalign and the score no longer corresponds to its timestamp. Expand the axis and all three in one mv-expand.
Q5. How would you hunt for beaconing in KQL? Compute inter-arrival times between a host and each remote destination (sort, then prev() under serialize), aggregate per stream, and flag streams with a low coefficient of variation (stdev ÷ mean) of the intervals — machine-regular timing. Alternatively use make-series plus series_periods_detect() to find a dominant period.
Q6. Which tables carry lateral-movement signal, and what discriminates it? SecurityEvent 4624 (LogonType 3 network, 9 NewCredentials, 10 RDP), DeviceLogonEvents, and IdentityLogonEvents. The discriminator is a new source-account→destination-host pair — build a baseline of seen pairs and leftanti-join to keep only the unseen.
Q7. What does UEBA’s InvestigationPriority represent and how do you use it? It’s a 0–10 score folding many behavioural features (first-time-from-country, uncommon-for-user, etc.) into one number in BehaviorAnalytics. You rank activities by it and filter to the high band, then pivot to the entity page — it collapses a multi-signal ATO/insider hunt into a sort.
Q8. How do you promote a hunt to a detection responsibly? Validate the candidate on livestream for 24–48h to size false positives, confirm it clears the readiness checklist (fidelity, actionability, entity mapping, ATT&CK tag, ownership, suppression), then deploy it as an analytics rule in IaC with a watchlist suppressing known-good.
Q9. When would you use a summary rule vs ADX? Summary rules pre-aggregate a high-volume table into a compact _CL table on a schedule so you hunt cheaply within Sentinel; ADX is for very large volumes or multi-year retention queried cross-service via adx(). Summary rules reduce query cost within Log Analytics; ADX offloads storage and compute entirely.
Q10. Why is MSTICPy config in msticpyconfig.yaml rather than in cells? So connection strings and API keys are never hardcoded or committed — the file references secrets via EnvironmentVar, and MSTICPYCONFIG points to it. It also makes notebooks portable and reviewable as code.
Q11. A rule you deployed shows no subTechniques in its JSON. Why? The alertRules API version is too old; subTechniques is honoured only on 2023-02-01 and newer. Pin a current API version and redeploy.
Q12. How do you detect an illicit consent grant (T1528)? Hunt AuditLogs for Consent to application events where the target app is newly registered, multi-tenant, requests high-value Graph scopes (Mail.Read, Files.Read.All, full_access_as_app), and the granting user is not an admin — then suppress sanctioned apps via a watchlist. Maps to SC-200 identity-threat content.
These map most directly to SC-200 (Security Operations Analyst) — hunting, KQL, Sentinel, and Defender XDR are core exam domains — and touch SC-100 (designing a SOC) and AZ-500 (monitoring).
Quick check
- What three parts must a hunt hypothesis contain?
- Which
SigninLogsResultTypeindicates invalid-credential failures you’d use for a spraying hunt? - What statistical property of inter-arrival times flags beaconing?
- What is the difference between intended and effective ATT&CK coverage?
- Which validation step sizes a candidate rule’s noise before promotion?
Answers
- The technique (an ATT&CK T-ID), the observable (the table and signal), and the falsifiable claim (the condition, including what a negative result looks like).
50126— “invalid username or password.” Many accounts with 50126 from one IP in a short window is the spraying signature.- A low coefficient of variation (standard deviation ÷ mean) of the intervals — machine-regular, low-variance timing between connections to the same destination.
- Intended coverage means a rule is tagged with the technique; effective coverage means the rule actually fired and was triaged in the measurement window. Only effective coverage reflects real detection.
- Hunting livestream — running the candidate query against live incoming data for 24–48h to observe the real hit rate and false-positive shape before promoting it to a standing rule.
Glossary
| Term | Definition |
|---|---|
| Threat hunting | Proactive, human-led, hypothesis-driven search for adversary behaviour that automated detections miss |
| KQL | Kusto Query Language — the read-only analytics language for Sentinel and Defender advanced hunting |
| MITRE ATT&CK | A knowledge base of adversary tactics (goals) and techniques (methods), each with an ID |
| Tactic | The adversary’s objective in a phase (e.g. Lateral Movement, TA0008) |
| Technique / sub-technique | A specific way to achieve a tactic (e.g. T1021.001 RDP), with a T-ID |
| PEAK | Prepare, Execute, Act with Knowledge — a hunting framework with three hunt types |
| Hunting Maturity Model (HMM) | A 0–4 scale rating a program from alert-only to hunt-automating |
| UEBA | User and Entity Behavior Analytics — Sentinel’s per-entity behavioural baselining engine |
| BehaviorAnalytics | The UEBA output table with per-activity enrichment and InvestigationPriority |
| InvestigationPriority | A 0–10 UEBA score folding many behavioural features into one number |
| Bookmark | A saved hunting-query result row with notes and entity mappings; the unit of evidence |
| Livestream | Near-real-time execution of a hunting query against live data for validation |
| Analytics rule | A saved Sentinel query that runs on a schedule/NRT and raises incidents |
| Summary rule | A scheduled KQL aggregation that distils high-volume data into a compact table |
| MSTICPy | Microsoft’s open-source Python library for hunting, enrichment, and visualization in notebooks |
| Stack counting | Aggregating an attribute across the fleet and inspecting the low-prevalence tail |
| Beaconing | Regular, machine-timed outbound communication characteristic of C2 |
| Watchlist | Externalised reference data (VIPs, known-good, TI) queried via _GetWatchlist |
| ADX | Azure Data Explorer — a Kusto engine for massive-volume/long-retention data, queried via adx() |
| Pyramid of Pain | A ranking of indicators by how costly they are for an adversary to change; TTPs sit at the top |
Next steps
- Deepen the KQL foundation with KQL for Azure Monitor and Log Analytics: From Joins to Time-Series, Without Blowing the Budget and KQL That Earns Its Keep.
- Onboard the SIEM as code with the Terraform Module: Azure Microsoft Sentinel, then design the workspace with How to Create a Log Analytics Workspace.
- Shrink the attack surface you hunt using Operationalizing Entra ID Protection and Governing OAuth Consent and Application Permissions in Entra ID.
- Prepare the handoff a confirmed hunt triggers with Engineering Incident Response: Runbooks, Tabletop Exercises, and Cloud Forensics, and threat-model new systems with Practical Threat Modeling: STRIDE, Data-Flow Diagrams, and Attack Trees.
- Extend hunting to SaaS and cloud posture via Taming Shadow IT and Risky SaaS and Enabling Microsoft Defender for Cloud in Your First Week.