A two-stage security operations lab built on VMware, simulating real-world attack chains (brute force, credential dumping, lateral movement) against a deliberately vulnerable Windows Server, with both Splunk and Wazuh deployed as SIEMs over the same telemetry. The lab is the source of every detection, dashboard, and IR artifact in the writeup.
Goal: Build a realistic, isolated environment to practice log analysis, detection engineering, and incident response without burning a cloud account — and to validate the question "would I actually catch a real adversary on this stack?" against myself.
- Architecture
- Environment
- Stage 1 — Splunk deployment
- Stage 2 — Wazuh (containerized)
- Attack simulations & detection
- Detection engineering: what failed & what fixed it
- MITRE ATT&CK coverage
- Lessons & what I'd rebuild
- References
The lab is dual-homed to balance two competing requirements: enough isolation to safely run live malware and brute-force traffic against the local home network, and enough internet access to pull updates, packages, and threat intel feeds when needed.
flowchart TB
subgraph Internet["Internet"]
direction LR
Updates["Updates, threat feeds,<br/>Wazuh/Splunk downloads"]
end
subgraph Host["Host Machine (macOS)"]
direction LR
HostNet["Host network<br/>192.168.86.0/24"]
end
subgraph VBox["VMware Workstation"]
direction TB
end
Host --> VBox
subgraph LabNet["Isolated Lab Network — Host-Only (192.168.86.0/24)"]
direction TB
Kali["Kali Linux<br/>attacker<br/>192.168.86.130<br/>2 vCPU · 2 GB · 20 GB"]
Windows["Windows Server 2025<br/>victim<br/>192.168.86.129<br/>1 vCPU · 2 GB · 64 GB<br/>Sysmon (SwiftOnSecurity →<br/>Olaf Hartong modular)<br/>Wazuh Agent"]
Ubuntu["Ubuntu Server<br/>SIEM host<br/>192.168.86.131<br/>2 vCPU · 16 GB · 50 GB"]
end
VBox --> LabNet
subgraph UbuntuBox["Ubuntu Server internals"]
direction TB
Docker["Docker Engine"]
subgraph Stack["docker-compose stack"]
Wazuh["Wazuh Manager<br/>+ Indexer<br/>+ Dashboard<br/>:55000 / :9443"]
end
Docker --> Stack
end
Ubuntu -. "Wazuh Agent<br/>TCP 1514" .-> Stack
Kali -. "RDP 3389<br/>SMB 445<br/>WinRM 5985" .-> Windows
Windows -. "Sysmon +<br/>Security/Application<br/>event logs" .-> Stack
Internet -. "Outbound only<br/>(toggleable via script)" .-> Ubuntu
Internet -. "Pull packages" .-> Kali
style Kali fill:#2b1d1d,stroke:#ff2e63,color:#fff
style Windows fill:#1d2b2b,stroke:#00ffd5,color:#fff
style Wazuh fill:#0a0e14,stroke:#00ffd5,color:#fff
style Docker fill:#0a0e14,stroke:#00ffd5,color:#fff
style Internet fill:#0a0e14,stroke:#666,color:#fff
style Host fill:#0a0e14,stroke:#666,color:#fff
Why dual-homed and not just one adapter?
A single NAT adapter on each VM still routes traffic through the host's home network. With Mimikatz, PsExec, and brute-force traffic in play, that's an unacceptable blast radius. The fix:
- Adapter 1 — Host-Only (192.168.86.0/24): the only network the three lab VMs live on. Kali can reach Windows, Windows can reach the Ubuntu SIEM, nothing reaches the home LAN.
- Adapter 2 — NAT: outbound internet only. Disabled by default on
Kali via a small bash toggle (
ifdown eth1/ifup eth1) so the attacker VM only talks to the lab subnet unless I explicitly want it to pull a package.
A real SOC analyst will recognize this as the same logic that drives production network segmentation: the analyst workstation and the monitored endpoints live on different trust zones, and the SIEM tier sits in its own.
| Component | Specs | Role |
|---|---|---|
| Kali Linux | 2 vCPU, 2 GB RAM, 20 GB disk | Attacker platform (Hydra, Nmap, Mimikatz, Impacket) |
| Windows Server 2025 | 1 vCPU, 2 GB RAM, 64 GB disk | Victim — deliberately weakened (Defender off, SMBv1 on, weak local account, RDP without NLA) |
| Ubuntu Server 22.04 | 2 vCPU, 16 GB RAM, 50 GB disk | SIEM host (Docker + Wazuh stack) |
| VMware Workstation | — | Hypervisor; provides the dual NIC topology above |
| Sysmon | SwiftOnSecurity config → Olaf Hartong modular (Research) | Endpoint telemetry enrichment on Windows |
The Windows VM is intentionally a worst-practice configuration so detection content has something to fire on. Do not deploy any of these settings outside a lab.
Before Wazuh, the lab ran on Splunk. The motivation was the same: a
proper SIEM, not a grep over Event Viewer. The Universal Forwarder on
Windows was the first real lesson in agent-to-indexer plumbing.
The first detection end-to-end was a Hydra brute force on the local Administrator account, captured with this SPL:
index=* sourcetype="WinEventLog:Security"
(EventCode=4625 OR EventCode=4624)
| eval Status=case(EventCode=4625, "Failed Attempt",
EventCode=4624, "Successful Login")
| timechart span=1m count by Status
The chart shows 8 failed logon attempts at 04:01 from a single source — the textbook signature of password spraying. The same investigation continues into the Event 4625 detail:
| Field | Value |
|---|---|
| Account Name | Administrator |
| Workstation Name | kali |
| Source Network Address | 192.168.86.130 |
| Source Port | 0 |
| Logon Process | NtLmSsp |
| Authentication Package | NTLM |
| Failure Reason | Unknown user name or bad password |
| Status / Sub Status | 0xC000006D / 0xC000006A |
The source IP matching the Kali subnet is the smoking gun — it's why recording the attacker IP at the alert level (not just the count) was worth the extra SPL.
The first SOC dashboard was a single panel: a Threat Gauge of failed logon attempts against a pie chart of victim usernames, with a timeline of breach attempts. Basic, but a solid foundation for the mental model of "event source → indexer → search head → dashboard."
Verdict on Splunk: It works, the SPL is genuinely powerful, and the dashboards are first-class. For a learning lab, it's expensive and the free tier caps ingestion. The pivot to Wazuh was driven by cost and a real interest in seeing what an open-source XDR-grade SIEM gives you out of the box.
Wazuh is a heavier stack than Splunk: manager + indexer + dashboard, each with their own resource profile. The original plan to run it side-by-side with Splunk on a 4 GB VM failed in the obvious way (50 GB of log volume at boot, OOM on the manager service). The containerized approach was the right move but needed real work to get right.
Spin-up order that worked:
- Fresh Ubuntu Server 22.04 install.
- Install Docker +
docker-compose. - Pull the Wazuh single-node stack YAML.
- Set
vm.max_map_count=262144on the host (Elasticsearch requirement). - Extend the LVM logical volume — the default Ubuntu Server install only allocated ~24 GB of the 50 GB virtual disk to the root LV, and Wazuh filled it inside an hour.
- Deploy the Wazuh agent on the Windows box using the manager's IP,
explicitly set in
ossec.conf— the agent silently fails to register without that.
Dashboard after first agent enrolls: the standard Wazuh overview shows the agent's endpoint posture in summary form — agent count, alert breakdown by severity, MITRE ATT&CK shortcuts, and IT-hygiene / compliance panels (PCI DSS, GDPR, HIPAA, CIS benchmarks) out of the box. The numbers worth noting at first ingest were:
| Panel | Value at first ingest |
|---|---|
| Critical alerts (24h) | 0 |
| High alerts (24h) | 0 |
| Medium alerts (24h) | 316 |
| Low alerts (24h) | 647 |
| Active agents | 1 |
| MITRE techniques observed | 9 Impact, 1 Defense Evasion |
The vulnerability count without any attack traffic is the more interesting number. The Vulnerability Detection panel on a default Windows Server 2025 install against Wazuh's built-in vulnerability scanner produced:
| Severity | Count |
|---|---|
| Critical | 12 |
| High | 574 |
| Medium | 264 |
| Low | 6 |
That's a useful baseline for any "is this VM production-ready" decision — and a strong argument for why even a freshly-installed Windows host benefits from continuous vulnerability scanning in a real environment.
Each scenario below follows the same shape: setup → execution → detection → result. Every detection quoted is a real Wazuh rule ID that fired in this lab, with the rule description verbatim from the alert.
Setup. Created a local account Administrator with a weak
password. Enabled RDP without Network Level Authentication. Hydra
runs from the Kali host against the Windows host on the lab subnet.
Execution.
hydra -l Administrator -P /usr/share/wordlists/rockyou.txt \
-t 4 -V 192.168.86.129 rdpDetection — Splunk stage (4625 + 4624 SPL above): 8 failed logon attempts in a single minute, single source IP. Splunk visualizes the pattern; the analyst has to do the rest.
Detection — Wazuh stage (rule 1014, level 10):
Rule 1014 (Level 10): Successful Remote Logon Detected — User: Administrator — NTLM authentication, possible pass-the-hash attack. Source IP:
192.168.86.130(Kali).
Hydra finally landed a valid credential. The level-10 severity is because the rule chain is built to flag exactly the post-bruteforce "first successful NTLM logon from a host that just generated a 4625 storm" scenario. This is the kill shot alert. A SOC L1 seeing this rule fire on a production network would open a P1 ticket immediately.
Result. Credential compromise of the local Administrator account. The account was reset and the source IP added to the watchlist for the duration of the engagement.
Setup. The Windows VM has SMBv1 enabled and a writable C$ share
(the default for local admins). Mimikatz is staged on Kali and dropped
to the victim over SMB.
Execution.
# On Kali — drop mimikatz to the victim's C: drive over SMB
smbclient //192.168.86.129/C$ -U Administrator
> put mimikatz.exe
# Pivot to Impacket PsExec for execution
impacket-psexec Administrator:'Admin!'@192.168.86.130
C:\> C:\mimikatz.exe "privilege::debug" "sekurlsa::logonpasswords" "exit"Detection — file drop (rule 92218, level 6):
Rule 92218 (Level 6): Possible abuse of Windows admin shares by binary dropped in Windows root folder by system process.
This is the Wazuh rule that fires on a binary landing in C:\ via
SMB, an unusual path for legitimate executables. Source IP again
192.168.86.130.
Detection — service install (rule 67027 / 92650, level 3 / 12):
Rule 92650 (Level 12): New Windows Service Created to start from win... Binary path:
C:\mimikatz.exe. Event IDs 4688 (process creation) and 7045 (service install).
A level-12 alert on a brand-new service whose image path is a
suspicious binary in C:\ is the kind of detection that doesn't
require a known-bad signature. It fires on the shape of the
behavior, which is what makes it useful against novel tooling.
Detection — command line (Sysmon + 4688 with audit policy):
Rule 92052 (Level 4): Windows command prompt started by an abnormal process.
The full command line is preserved in the alert:
C:\mimikatz.exe "privilege::debug" "sekurlsa::logonpasswords" "exit"
This is where the Advanced Audit Policy Configuration → Detailed
Tracking → Include Command Line in Process Creation Events setting
earns its keep. Without it, the alert would just say
mimikatz.exe ran; with it, the full intent is captured.
Result — and an important failure mode. The
sekurlsa::logonpasswords module did not produce cleartext
credentials in the Wazuh alert. Reason: Credential Guard on
Windows Server 2025 isolates the LSASS process from any
non-TrustedInstaller process — including an admin-context Mimikatz.
The token impersonation succeeded (NT AUTHORITY\SYSTEM), but the
read of LSASS memory was blocked.
The alternative lsadump::sam module, which reads the SAM registry
hive rather than LSASS, still worked and dumped NTLM hashes:
Administrator:500:
aad3b435b51404ee:aad3b435b51404ee:
580b16d486d8d2cafa00b314d41fa396:::
This is the part of the writeup that I think is most useful for a junior SOC analyst: the lab hit a real modern defense, and the detection chain (file drop → service install → abnormal cmd → command line capture) still fired end-to-end. A detection that fires even when the actual attack goal is partly blocked is worth more than one that only fires on a successful compromise.
Setup. After the Mimikatz run, the same Kali box attempts to move laterally using Impacket's PsExec against the same victim (in this lab, the "lateral" target is a second victim on a separate subnet; for the simulation, the same host is used to keep the lab minimal).
Execution.
impacket-psexec Administrator:'Admin!'@192.168.174.130 \
cmd.exe
# Impacket uploads KKtqVIHf.exe to ADMIN$, creates service RlJW,
# executes, returns nt authority\systemDetection — service-from-random-binary (rule 92307, level 3):
Rule 92307 (Level 3): Evidence of new service creation found in registry under
HKLM\System\CurrentControlSet\Services\sJXO. Binary path:C:\LovMhziA.exe.
The interesting signal here is the binary name. Impacket generates
random 8-character names per engagement (KKtqVIHf.exe,
LovMhziA.exe, sJXO for the service). A service name of 8 random
chars pointing at an C:\ binary that wasn't there yesterday is
a high-fidelity PsExec signature. Wazuh's syscheck + sysmon
integration plus the New-Windows-Service-Created rule cover
exactly this case.
Result. Service-based lateral movement detected, attacker
achieved nt authority\system on the target. Detection fired at
file-creation time, before the service was started — fast enough
to act on if a real SOC were watching.
A lab isn't useful if everything works the first time. These are the real problems I hit, in order.
Symptom. Ran nmap -sS 192.168.86.129 from Kali. Wazuh
dashboard stayed quiet. pfirewall.log on Windows showed the
connections; Wazuh didn't surface them.
Root cause. The default Wazuh agent config explicitly excludes Event IDs 5156 (Windows Firewall let a connection through) and 5157 (Windows Firewall blocked a connection) — they're noisy in production.
Fix. secpol.msc → Advanced Audit Policy Configuration →
Object Access → enable Audit Filtering Platform Connection
and Audit Filtering Platform Packet Drop, both Success and
Failure. Wazuh then started showing connection events.
Lesson. Don't trust the default <rule> blocks in
ossec.conf blindly. They optimize for production noise; a lab
needs the opposite posture.
Symptom. Sysmon was installed but the lab's alerts were thin relative to the actual activity.
Root cause. The SwiftOnSecurity config is production-tuned to ignore noise. Most of the attacks in this lab — particularly the remote-originated ones — fall into the "ignored" bucket.
Fix. Switched to Olaf Hartong's Sysmon-Modular (the Research variant). It logs more events by default and the modular structure makes it easy to add/remove include rules without rewriting the whole config.
Lesson. The right Sysmon config depends on what you're defending against. Production ≠ research.
Symptom. Wazuh service refused to start. No error in
systemd, no error in journalctl. Just dead.
Root cause. Default Ubuntu Server installer only allocates ~24 GB of the 50 GB virtual disk to the root logical volume. Wazuh filled it on first run.
Fix. Extended the LV with lvextend and resized the
filesystem with resize2fs. Permanent fix.
Lesson. Disk-full failures in containerized stacks often
present as opaque service-start failures. Check df -h first,
every time.
Symptom. Agent installed on Windows, service started, but the manager never showed it. No error message in the agent log either.
Root cause. ossec.conf on the Windows side didn't have
the manager IP explicitly set. The agent tried to find it via
the default broadcast and failed silently.
Fix. Set <server><address>192.168.86.131</address></server>
explicitly in ossec.conf and restarted the agent service.
Lesson. Read the actual log files, not just systemctl status. Wazuh's agent-side logs are verbose; the answer is
in there.
| Tactic | Technique | Sub-technique | Lab evidence |
|---|---|---|---|
| Credential Access | T1110 Brute Force | T1110.001 Password Guessing | Hydra → 8 × Event 4625 (Splunk) |
| Credential Access | T1003 OS Credential Dumping | T1003.002 Security Account Manager | lsadump::sam output (Mimikatz) |
| Credential Access | T1003 OS Credential Dumping | T1003.001 LSASS Memory (blocked by Credential Guard) | sekurlsa::logonpasswords attempt (Mimikatz) |
| Lateral Movement | T1021 Remote Services | T1021.002 SMB/Windows Admin Shares | smbclient drop, ADMIN$ abuse (rule 92218) |
| Lateral Movement | T1569 System Services | T1569.002 Service Execution | Impacket PsExec service install (rules 67027, 92307, 92650) |
| Execution | T1059 Command and Scripting Interpreter | T1059.001 PowerShell, T1059.003 Windows Command Shell | cmd.exe chain, abnormal-cmd rule 92052 |
| Defense Evasion | T1027 Obfuscated Files or Information | — | Random-named PsExec service binaries |
| Defense Evasion | T1562 Impair Defenses | T1562.001 Disable or Modify Tools | Defender disabled on victim (intentional) |
| Persistence | T1543 Create or Modify System Process | T1543.003 Windows Service | Mimikatz / PsExec service install (rule 92650) |
| Impact | (observed) | — | Wazuh MITRE panel — 9 Impact, 1 Defense Evasion on first ingest (vulnerability scanner noise) |
What I learned the hard way:
- Default SIEM configs optimize for production noise, not lab
signal. The Wazuh defaults that exclude 5156/5157 firewall
events are the right call in a 10k-endpoint deployment and
the wrong call for a 1-VM lab. Read every
<rule>block. - The audit policy is the detection floor. A SIEM is
useless if the OS isn't generating the events the SIEM
matches against. Command-line auditing (4688 with
Include Command Line) and object-access auditing (5156/5157) are the two settings that turned this lab from "pretty dashboards" into "actual detections." - Credential Guard is real, and it's the default on modern
Windows Server. A junior analyst reading older Mimikatz
tutorials will get confused when
sekurlsa::logonpasswordssilently fails. Knowing that SAM dumping still works is the practical takeaway. - Sysmon config is a project, not a setting. The SwiftOnSecurity vs Olaf Hartong choice represents a real trade-off between production signal-to-noise and lab/research coverage. For detection engineering in a SOC, you'd probably run a hybrid — Hartong's base, with production noise-rules layered on top.
- The most important SPL/Sigma rule in a SOC isn't the one
that fires on a known-bad hash. It's the one that fires on
"binary with random 8-character name dropped to
C:\and registered as a Windows service" — because that pattern covers a family of attacks, not a single sample.
What I'd rebuild:
- Add Suricata or Zeek on a SPAN port between the lab switch and the victim. The Wazuh stack is endpoint-centric; adding network telemetry (DNS logs, TLS SNI, HTTP requests) would catch the lateral movement earlier in the kill chain.
- Centralize logs into the home SIEM, not just the lab one. A Wazuh manager on the host machine that ingests lab agent logs would let me run Sigma rules against the live data.
- Time-bound the lab with an automated attack script. A cron that fires a random MITRE technique every hour, with alerting on a separate channel, would let me validate detection coverage continuously instead of one-off.
- Wire a real threat intel feed. AbuseIPDB or EmergingThreats Suricata rules would turn a portion of the Wazuh alerts into "known-bad source" classifications, which is closer to how a real SOC triages.
- Wazuh documentation — https://documentation.wazuh.com/
- Sysmon Modular (Olaf Hartong) — https://github.com/olafhartong/sysmon-modular
- SwiftOnSecurity Sysmon config — https://github.com/SwiftOnSecurity/sysmon-config
- MITRE ATT&CK — https://attack.mitre.org/
- Impacket — https://github.com/fortra/impacket
- Mimikatz — https://github.com/gentilkiwi/mimikatz
- Bejtlich, R. The Practice of Network Security Monitoring (free PDF, no relation to this project, just the book that taught me how to think about a SOC)