CoursesDetection engineeringHypothesis-driven hunting

Hypothesis-driven hunting

Assume breach; hunt what alerts miss.

Advanced30 min · lesson 7 of 15

An automated detection is a smoke alarm. It goes off for the fires you predicted well enough to wire a sensor for, and it stays silent about everything else. Threat hunting is the fire marshal walking the building at 2 a.m., checking the frayed wiring nobody ever put an alarm on. It is proactive, *assume-breach* work. You start from the premise that an intruder is already inside, then go looking for them in your telemetry (the constant stream of logs and events your laptops, servers, identity provider and cloud APIs give off), instead of waiting for a rule to trip. Automation catches the known. Hunting catches the unknown.

Why hunt when you already have alerts?

Every alert you own is a bet somebody already placed. A person predicted a threat, wrote a rule for it, and now the rule watches that one thing forever. Whatever nobody predicted sails straight past: a fresh living-off-the-land trick (the attacker using the admin tools already installed on the machine, so nothing looks foreign), a stolen service-account credential, an implant that rode in through a build you trusted. Public incident data keeps putting attacker dwell time (the gap between the break-in and the moment anyone notices) at days to weeks. Hunting exists to shrink that gap by probing where an adversary *would* be if the assume-breach premise is true. Hunting does not replace automated detection. It is the research function that feeds it. The prize is rarely one arrest. The prize is turning an analyst's fleeting hunch into permanent coverage that keeps working long after that analyst has gone home.

What a testable hypothesis actually looks like

A hunt is a hypothesis plus a query. It is never an open-ended 'let us poke around and see what turns up'. Treat it like a lab experiment: you write the claim first, then run the test that could prove you wrong. A good hypothesis is specific, falsifiable and bounded in time, and it names four things. The behavior you expect the adversary to show. The data source that would record it. The signal that separates malicious from ordinary. The time window you will look at. 'An attacker is reusing a valid account for lateral movement (hopping from the machine they first landed on to more valuable ones), so one account will authenticate to far more hosts than its baseline over seven days' is testable. 'Check for lateral movement' is not. Most hypotheses come from MITRE ATT&CK techniques (a public catalogue of the tactics and techniques real intrusion groups use, and the focus of the next lesson) or from threat intel about your own sector. Mature teams hang the work off a framework such as PEAK (Prepare, Execute, Act with Knowledge) or TaHiTI (Targeted Hunting integrating Threat Intelligence). The discipline matters more than the acronym: write the hypothesis down *before* you touch the data, so a result of nothing still tells you something.

Run the hunt: a query, not a vibe

With the hypothesis pinned down, turn it into a query against your SIEM (security information and event management platform, the searchable store where all your logs land). The example below is KQL (Kusto Query Language), the language Microsoft Defender and Sentinel use for advanced hunting. The same logic ports to Splunk's SPL (Search Processing Language) as stats dc(Computer) as hosts by Account | where hosts > 15. You are counting how many distinct hosts each account logs into per day, then keeping the outliers. dcount counts distinct values, make_set grabs a sample of host names so you can eyeball them, and bin(Timestamp, 1d) buckets everything by day. The two rows that come back are exactly what a hunter picks up and triages.

Microsoft Defender / Sentinel advanced hunting (KQL)
// Hypothesis: an attacker is reusing a valid account for lateral movement,
// so one account authenticates to far more hosts than its baseline over 7d.
DeviceLogonEvents
| where Timestamp > ago(7d)
| where ActionType == "LogonSuccess"
| where LogonType in ("Network", "RemoteInteractive") // SMB / RDP-style logons
| summarize DistinctHosts = dcount(DeviceName),
Hosts = make_set(DeviceName, 25)
by AccountName, bin(Timestamp, 1d)
| where DistinctHosts > 15 // tune to YOUR environment's baseline
| order by DistinctHosts desc
// AccountName Timestamp DistinctHosts Hosts
// svc_backup 2026-07-08 42 [FS01, FS02, WKS114, ...] <- a service acct should hit ~3 hosts, not 42
// jsmith 2026-07-09 19 [WKS22, WKS23, DC01, ...] <- a workstation user touching a domain controller?
// (2 rows returned)

Two leads jump out. A *service* account touching 42 hosts, when service accounts are supposed to be boringly predictable. And an ordinary desk user authenticating to a domain controller (the server holding the keys to the whole Windows domain). Neither row is proof of anything, and that is the point. A hunt produces *leads*. A human then confirms them or clears them. A query returning rows is not a confirmed incident, in the same way a smoke alarm going off is not a fire until somebody walks down the corridor and looks. A hunt that comes back with zero rows is not a failure either. Zero rows either supports the hypothesis (the behavior genuinely is not happening) or tells you that you cannot answer the question at all, which is the far more dangerous of the two. The next step is how you tell them apart.

Prove the hunt can see: emulate the technique

A metal detector that never beeps might mean nobody is carrying a knife, or it might mean the batteries are flat. You cannot tell from the silence. You find out by walking a knife through it yourself. Atomic Red Team is an open-source library of small, ATT&CK-mapped tests (called atomics) that reproduce one technique at a time in a controlled way, and Invoke-AtomicTest is its PowerShell runner. Detonate the matching technique in a lab, re-run the hunt, and check whether the events appear. If they do, your visibility is sound and the empty hunt was telling the truth. If they do not, you have found a visibility gap: a log source nobody onboarded, or a field that gets dropped on the way in. That gap is a real, durable finding. Raise a ticket for it.

Emulate the technique with Atomic Red Team (PowerShell)
# Install once, in an ISOLATED lab VM (see the warning below)
PS> Install-Module -Name invoke-atomicredteam, powershell-yaml -Scope CurrentUser -Force
PS> Import-Module invoke-atomicredteam
# Inspect what a technique's atomics actually do (T1021.002 = SMB/Windows Admin Shares)
PS> Invoke-AtomicTest T1021.002 -ShowDetailsBrief
# T1021.002-1 Map admin share
# T1021.002-2 Map Admin Share PowerShell
# T1021.002-3 Copy and Execute File with PsExec
# T1021.002-4 Execute command writing output to local Admin Share
PS> Invoke-AtomicTest T1021.002 -GetPrereqs # fetches PsExec (needed by test 3), etc.
PS> Invoke-AtomicTest T1021.002 -TestNumbers 1 # 'Map admin share' -> emits LogonType Network events
# Executing test: T1021.002-1 Map admin share
# Done executing test: T1021.002-1 (1 test executed)
# Now re-run the KQL hunt: did the lab account surface? If NOT -> visibility gap.
PS> Invoke-AtomicTest T1021.002 -TestNumbers 1 -Cleanup # ALWAYS clean up
Atomic Red Team detonates real attacker behavior
Invoke-AtomicTest performs genuinely hostile actions: mapping admin shares, dropping PsExec, launching credential-access tooling. Never point it at a production endpoint or at a domain-joined machine you care about. Your EDR (endpoint detection and response agent) may quarantine the host, and -GetPrereqs downloads binaries from the internet. Detonate only inside an isolated lab VM you snapshotted first, always pass -Cleanup afterwards, and warn your SOC (security operations center, the team watching the alerts) before you start, so nobody mistakes your test for a live break-in. In a deliberate purple-team exercise you may want the opposite: let the SOC work it as if it were real, and see how far they get.

Codify it, or you will hunt the same thing next quarter

The loop closes when a confirmed finding becomes an automated detection, so the next occurrence gets caught with nobody in the chair. Rather than hand-writing a separate query for every platform you own, write the logic once as a Sigma rule (a vendor-neutral detection format written in YAML, a plain-text config syntax, and the subject of its own lesson), then compile it for your platform with sigma-cli, the official pySigma command-line tool. Below, sigma convert turns the rule into the same KQL you hunted with, ready to commit to your detection-as-code repository, where CI (continuous integration, the automated pipeline that runs on every commit) tests it and ships it. A hunt that ends without a detection, a tuning or a written-up gap has leaked its value. You will rediscover the same blind spot in three months and pay for it twice.

Codify the finding: sigma-cli to KQL detection
# rules/valid_accounts_lateral.yml (detection excerpt)
# detection:
# selection:
# ActionType: LogonSuccess
# LogonType:
# - Network
# - RemoteInteractive
# condition: selection
$ pipx install sigma-cli
$ sigma plugin install kusto # pySigma backend for Microsoft XDR / Sentinel
$ sigma convert -t kusto -p microsoft_xdr rules/valid_accounts_lateral.yml
DeviceLogonEvents
| where ActionType == "LogonSuccess" and LogonType in ("Network", "RemoteInteractive")
# Commit the .yml to your detection-as-code repo; CI validates + deploys it
# (see the "Detection as code" and "Testing detections" lessons).
The hunting loop
1Hypothesis
assume-breach, ATT&CK-derived, testable, time-bounded
2Emulate
Atomic Red Team generates the telemetry safely
3Hunt
query the SIEM to prove or disprove it
4Finding or gap
an intruder lead, or a visibility blind spot
5Codify
tested Sigma detection, or a new log source
Hunting finds what automation missed, emulation proves the hunt can see, and codifying turns each result into permanent coverage that feeds the next hypothesis.

What it costs, and what to measure

Hunting is expensive. It needs skilled people, telemetry retained long enough to look backwards through, and reliable baselines, because a hunt for 'abnormal' means nothing until you can say what normal looks like. Be disciplined about scale and cost too. A DeviceLogonEvents | where Timestamp > ago(90d) sweep across hot storage can time out and burn real money in ingest and compute charges, so bound every hunt by time and pre-aggregate into summary or materialized tables. The trade-off is blunt. Automation is cheap, repeatable and blind to anything new. Hunting is costly, human, and finds what automation missed. Measure the program by what it produces, not by hours logged: hunts run, detections created, visibility gaps closed, and the drop in mean dwell time. Do not measure it by intruders caught, because a healthy program mostly finds gaps rather than attackers. Alert volume is the same trap in a different costume. A rule that fires two hundred times a day has not found two hundred incidents; it has usually found one badly tuned rule and one exhausted analyst. Finally, aim your hypotheses at durable adversary *behaviors* rather than swappable indicators like IP addresses and file hashes. That is exactly the argument of the Pyramid of Pain, which ranks ATT&CK techniques by how much they cost an attacker to change, and it is where the next lesson goes.

A good hunt fits in one falsifiable sentence: "If an attacker is using stolen service-account keys in project X, we will see CreateAccessKey or google.iam unusual privilege grants outside the break-glass group during off-hours." Then write down the data you need, the time range, the baseline you expect, and the result that would prove you wrong. A hunt with no disproof condition turns into storytelling. Keep a hunt log as well: hypothesis, queries, time spent, and outcome (lead, no lead, or detection candidate). That log is how you defend hunting time to a director who wants numbers, without hand-waving at vibes.

Point your backlog at the gaps automation cannot cover: living-off-the-land binaries that look identical to admin work, identity abuse that never trips a malware signature, and cloud control-plane sequences that hop between accounts. When a hunt turns up a lead, do not stop when the case closes. Draft the Sigma or correlation rule while the evidence is still fresh, and write the false-positive notes next to it, covering the paths that looked the same and turned out to be benign. Hunting that never becomes detection is expensive curiosity.

Try this

Run one hypothesis-shaped hunt in your lab SIEM for privileged commands executed out of hours. Then write down which of three things happened: the hypothesis held, the hypothesis failed, or you need better telemetry before you can answer at all.

terminal
// Hunt sketch (KQL) — "unusual whoami /priv after interactive logon"
DeviceLogonEvents
| where Timestamp > ago(7d) and LogonType == "Interactive"
| project LogonTime=Timestamp, DeviceName, AccountName
| join kind=inner (
DeviceProcessEvents
| where Timestamp > ago(7d)
| where FileName =~ "whoami.exe" and ProcessCommandLine has "/priv"
| project ProcTime=Timestamp, DeviceName, AccountName, ProcessCommandLine
) on DeviceName, AccountName
| where ProcTime between (LogonTime .. datetime_add('minute', 30, LogonTime))
| where hourofday(ProcTime) < 6 or hourofday(ProcTime) > 20
| project DeviceName, AccountName, LogonTime, ProcTime, ProcessCommandLine
# Review each row. Benign admin jump boxes become filter notes for a future rule.

Takeaway

Hunting assumes the breach already happened and goes looking where your alerts stay quiet. Write hypotheses you can prove wrong, time-box them, and turn every lead into a tested detection before the evidence goes cold.

Next step: log one hunt this week with its hypothesis, its query, its outcome and a draft rule stub. Even an outcome of "no lead" trains the program, because it tells you what you can and cannot see.

Quick check
01Your hypothesis-driven hunt for lateral movement comes back with zero rows. Going by this lesson, what do you do next, and why?
Correct — Zero rows is ambiguous: it can mean 'no adversary' or 'my telemetry is blind to this technique'. Generating the behavior yourself is the only way to tell the two apart.
Incorrect — No. The lesson calls this the far more dangerous reading. A log source that was never onboarded also returns zero rows, so absence has not been proven.
Incorrect — No. A zero-row hunt is explicitly not a failure, and relaxing the signal until something shows up throws away the falsifiable, time-bounded design you started with.
Incorrect — No. You have not yet shown your telemetry can even record the behavior, so shipping the rule bakes the visibility gap in and hides it behind a green tick.
02A colleague offers to prove the hunt query works by running Invoke-AtomicTest T1021.002 -TestNumbers 1 on a domain-joined workstation in the office. What is wrong with that plan?
Incorrect — No. Atomic Red Team performs the real actions: mapping admin shares, dropping PsExec, launching credential-access tooling. 'Safe' here means small and scoped, not harmless.
Correct — Your EDR may quarantine the host, -GetPrereqs pulls binaries down from the internet, and an unannounced detonation looks exactly like a live intrusion to whoever is on shift.
Incorrect — No. Skipping the prerequisite fetch avoids one download, but test 1 still maps an admin share on a machine you care about and still generates real logon events the SOC will chase.
Incorrect — No. The lesson says to coordinate with the SOC. Letting them work it as a real incident is a purple-team exercise everyone agreed to in advance, not a surprise sprung on production.
03Your hunt returns the two rows in the KQL output above: svc_backup on 42 hosts, and jsmith on 19 hosts including DC01. What is the right next move?
Correct — A hunt produces leads, not verdicts. Rows returned are not a confirmed incident, and the service account is the stranger of the two because service accounts are supposed to be predictable.
Incorrect — No. A firing query is a lead. Confirming an incident is a human judgement made after investigation, and disabling a backup service account on a hunch has a blast radius of its own.
Incorrect — No. DistinctHosts > 15 is meant to be tuned to your environment, and these are precisely the outliers the hypothesis predicted. Rows are the output you were hoping for.
Incorrect — No. Codify after you have confirmed or cleared the leads and captured what the benign look-alikes were. Shipping it now hands the on-call team a rule with no false-positive notes attached.

Related