I learn a SIEM by doing the same hunts in it over and over until the queries become reflex. Splunk is the second platform in my lab, running next to the Wazuh stack in my Docker lab, and these are the SPL queries I actually run against my own logs. Not a reference card copied from documentation. Every query below has earned its place by finding something in a simulation, most of them against the attacks in my 50 simulation results. Field names follow the Splunk Add-on for Microsoft Windows, which is what normalizes the event log fields I am using.
Failed Windows Logons by Source
spl
index=wineventlog EventCode=4625
| stats count by src_ip, user
| sort - count
The starting point for almost everything. Event 4625 is a failed logon, and grouping by source IP and account shows you instantly whether you are looking at one user mistyping a password or one machine working through a list. If you are new to these events, my Windows event IDs post explains what 4625 and its status codes mean before you query them.
Brute Force: Many Failures, One Source, Short Window
spl
index=wineventlog EventCode=4625
| bin _time span=5m
| stats count by _time, src_ip
| where count >= 10
The bin command buckets events into five minute windows so the count means failures per window, not failures across your whole search range. Ten failures in five minutes from one source is not a forgetful user. I tune the threshold against my own Hydra runs: the attack shows up in the thousands, normal lab noise stays under three.
Password Spray: Many Accounts, One Source
spl
index=wineventlog EventCode=4625
| stats dc(user) AS accounts_targeted, count AS attempts by src_ip
| where accounts_targeted >= 5
This is the query that catches what the brute force query misses. A spray stays under every per account threshold by trying one or two passwords against many accounts. dc, distinct count, flips the question around: not how many attempts, but how many different accounts one source touched. Five or more accounts from a single source in a lab my size is never normal. In a bigger environment you raise the number, but the shape of the query stays the same.
The One That Matters: Success After Failures
spl
index=wineventlog (EventCode=4625 OR EventCode=4624)
| stats count(eval(EventCode=4625)) AS failures,
count(eval(EventCode=4624)) AS successes
by src_ip, user
| where failures >= 5 AND successes > 0
Failures alone are an attempt. Failures followed by a success, event 4624, against the same account from the same source is a compromise until you prove otherwise. This is the first query I run when a brute force alert fires, because it answers the only question the alert does not: did they get in?
New Service Installed
spl
index=wineventlog EventCode=7045
| table _time, host, Service_Name, Service_File_Name, Start_Type
Event 7045, a service installed, is a persistence check. Most rows in a healthy environment are boring software installs. The rows I care about have a service file name pointing into a temp folder, a user profile, or anywhere that is not Program Files. Running this after any suspicious activity answers whether the attacker set up a way to survive a reboot.
Suspicious Process Creation
spl
index=wineventlog EventCode=4688
(New_Process_Name="*\\powershell.exe" OR
New_Process_Name="*\\certutil.exe" OR
New_Process_Name="*\\rundll32.exe")
| table _time, host, user, New_Process_Name, Process_Command_Line
Event 4688 is process creation, and the command line field is where the truth lives. PowerShell, certutil and rundll32 are all legitimate tools that attackers borrow constantly. I do not alert on the process alone. I pull the list, read the command lines, and look for encoded payloads, download URLs and odd parentage. In the lab this query is how I confirm what an attack script actually executed.
Failed SSH on the Linux Side
spl
index=os sourcetype=linux_secure "Failed password"
| stats count by src, user
| sort - count
Same hunt, other operating system. Linux secure logs carry the failed SSH attempts as plain text, and a simple text match plus a count by source gives you the brute force picture in one step. This is the query version of my LAB-001 investigation, which I worked in Wazuh.
DNS Tunnelling Hunt
spl
index=dns
| eval query_len = len(query)
| where query_len > 50
| stats count by src, query
| sort - count
Tunnelling hides data inside DNS queries, which makes the queries long, strange and frequent. len measures the query string, and anything past 50 characters deserves a look in a home lab. I pair it with a count of distinct queries per source, because a tunnelling tool generates hundreds of unique subdomains while a normal machine repeats the same short list. This hunt found nothing in my lab until I made DNS logging work properly, which is a lesson in itself: no logs, no hunt.
Rare Processes Baseline
spl
index=wineventlog EventCode=4688
| stats count by New_Process_Name
| sort count
The simplest query on this page and the one I run most. Sort process creation counts ascending and the bottom of the list is everything unusual on your network: the tool that ran once, on one machine, at a strange hour. Baselines are not glamorous, but most real hunting is exactly this, knowing what normal looks like well enough to see the one row that is wrong.
Next: converting these saved searches into scheduled alerts with thresholds matched to the lab, then doing the same set in Wazuh rules so both platforms watch for the same behaviour.