Stacking is the least glamorous hunting technique and the one that finds the most. You aggregate a field across the whole estate, sort ascending, and read the bottom of the list. Attackers are rare by construction: whatever they do, almost nobody else in your environment is doing it.
Pick a field worth stacking
A good stacking field is low-cardinality in a healthy estate, influenced by the attacker, and normalizable. Parent-child process pairs, service names and their image paths (7045), scheduled-task names, DLL load paths outside System32, HTTP user agents, TLS fingerprints, and destination ports by host all qualify.
Raw command lines do not. They contain GUIDs, temp file names and timestamps, so cardinality explodes and everything looks rare. A field where every value is unique carries no information.
Normalize before you count
Lowercase, and strip the parts that are unique per user or per run: profile paths, GUIDs, numeric temp names. If you stack full Sysmon Image paths without normalizing, C:\Users\alice\AppData\Local\Temp\a1.exe and C:\Users\bob\AppData\Local\Temp\b2.exe look like two unrelated one-off values rather than one pattern with two instances. Some sources save you the work: Microsoft Defender advanced hunting exposes FileName and InitiatingProcessFileName as bare executable names alongside the full paths.
Count hosts, not events
This is the part people get wrong. One host doing a rare thing five thousand times is one story; five thousand hosts doing it once each is a software deployment. Distinct host count is what decides whether a row is interesting.
Splunk over Sysmon process creation:
index=windows EventCode=1
| stats dc(ComputerName) as hosts, count as total, min(_time) as first_seen by ParentImage, Image
| where hosts <= 2
| convert ctime(first_seen)
| sort + hosts, + total
The same idea in KQL:
DeviceProcessEvents
| where Timestamp > ago(30d)
| summarize hosts = dcount(DeviceName), total = count(), first_seen = min(Timestamp)
by parent = tolower(InitiatingProcessFileName), child = tolower(FileName)
| where hosts <= 2
| order by hosts asc, total asc
Add a first-seen dimension
New and rare beats rare on its own. Something rare that has existed on three hosts for two years is almost always a line-of-business oddity. Something rare that first appeared on Tuesday, on the same host as your alert, is the hunt. That is why both queries above carry first_seen.
The stronger version of this is a diff: keep last week's stack as a baseline table and hunt only the values that are new this week. The volume you have to read drops by an order of magnitude.
Rare is not evil
Most of the long tail is misconfigured backup agents, a contractor's laptop and one developer's unusual toolchain. Budget your time and work the tail top-down by a single question: would an attacker want this capability? A signed binary spawning a shell outranks a rare printer driver.
Write down every benign explanation you confirm. Each one becomes an allowlist entry or a filter, so the next run of the same stack is cheaper. A hunt that does not shrink its own tail will not get run twice.
Graduate the hunt
- A tail value that turns out to be malicious becomes a detection rule with a true-positive sample.
- A tail that is consistently benign becomes a baseline, and the weekly diff against that baseline becomes a scheduled job.
Either way the manual hunt should not need repeating. Hunting is how you find the thing; detection engineering is how you stop having to look for it.
