Custom Wazuh rules
Four custom rules sit in the Wazuh syscheck group and key off real-time FIM events rather than syscall auditing. Each is ATT&CK-tagged and assigned a level that reflects how it should be triaged: the authorized_keys rule is level 12 because an attacker who can append a key owns persistent access; the others are level 10. The full ruleset, verbatim, deployed to the manager as rules/local_purple_rules.xml:
<group name="linux,syscheck,purple-team-fim,">
<rule id="100410" level="10">
<if_group>syscheck</if_group>
<field name="file" type="pcre2">^/etc/cron</field>
<description>Cron file created or modified: $(file) - possible scheduled-task persistence</description>
<mitre>
<id>T1053.003</id>
</mitre>
</rule>
<rule id="100411" level="10">
<if_group>syscheck</if_group>
<field name="file" type="pcre2">^/etc/systemd/system/.+\.service</field>
<description>Systemd unit created or modified: $(file) - possible service persistence</description>
<mitre>
<id>T1543.002</id>
</mitre>
</rule>
<rule id="100412" level="12">
<if_group>syscheck</if_group>
<field name="file" type="pcre2">\.ssh/authorized_keys$</field>
<description>SSH authorized_keys modified: $(file) - possible persistence via account manipulation</description>
<mitre>
<id>T1098.004</id>
</mitre>
</rule>
<rule id="100413" level="10">
<if_group>syscheck</if_group>
<field name="file" type="pcre2">^/usr/local/bin/</field>
<description>Binary placed in /usr/local/bin: $(file) - possible persistence or tooling drop</description>
<mitre>
<id>T1543</id>
</mitre>
</rule>
</group>The rules depend on the agent's syscheck block watching those paths in real time. That FIM configuration is a one-liner deployed to the agent:
<directories realtime="yes" check_all="yes" report_changes="yes">/etc/cron.d,/etc/cron.daily,/etc/systemd/system,/root/.ssh,/home/zion/.ssh,/usr/local/bin</directories>
Adversary emulation
A single Atomic Red Team script runs the technique set against the purple-target VM - a dedicated Ubuntu 22.04 host rather than a container, because kernel-level telemetry needs a real kernel. It drops a cron job in /etc/cron.d (T1053.003), creates a systemd unit in /etc/systemd/system (T1543.002), appends an SSH key to ~/.ssh/authorized_keys (T1098.004), plants an executable in /usr/local/bin (T1543), and fires 18 invalid SSH logins to trip the brute-force rule (T1110). The persistence actions, verbatim from the runner:
echo '* * * * * root /usr/bin/id' | sudo tee /etc/cron.d/atomic-persist >/dev/null
printf '[Unit]\nDescription=atomic test\n[Service]\nExecStart=/usr/bin/id\n[Install]\nWantedBy=multi-user.target\n' \
| sudo tee /etc/systemd/system/atomic-evil.service >/dev/null
mkdir -p "$HOME/.ssh"
echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAtomicRedTeamTestKeyDoNotUse attacker@evil' >> "$HOME/.ssh/authorized_keys"
printf '#!/bin/bash\nid\n' | sudo tee /usr/local/bin/definitely-not-malware >/dev/null
sudo chmod +x /usr/local/bin/definitely-not-malwareMITRE Caldera is provisioned via Docker Compose for graph-based emulation when an attack chain - rather than discrete atomics - is needed. It is built from upstream master and exposed on :8888:
services:
caldera:
image: caldera:local
build:
context: https://github.com/mitre/caldera.git#master
ports:
- "8888:8888"
- "8443:8443"
environment:
- CALDERA_CONF=local
command: ["--insecure", "--build"]
Coverage matrix
Every technique was executed on the endpoint and confirmed in the SIEM, mapped to ATT&CK and assigned a level that reflects how it should be triaged. Five of six fire on detections written for this lab; the sixth is a baseline check that login telemetry is reaching the manager.
| Technique | ATT&CK | Atomic action | Telemetry | Rule | Level | Detected |
|---|---|---|---|---|---|---|
| Brute Force | T1110 | 18 invalid SSH logins from one source | sshd auth log | 5712 (built-in) | 10 | yes |
| Scheduled Task / Cron | T1053.003 | write job to /etc/cron.d/ | FIM (real-time) | 100410 (custom) | 10 | yes |
| Systemd Service | T1543.002 | create unit in /etc/systemd/system/ | FIM (real-time) | 100411 (custom) | 10 | yes |
| SSH Authorized Keys | T1098.004 | append key to ~/.ssh/authorized_keys | FIM (real-time) | 100412 (custom) | 12 | yes |
| Create / Modify System Process | T1543 | drop binary in /usr/local/bin/ | FIM (real-time) | 100413 (custom) | 10 | yes |
| Valid Accounts / Sudo | T1078 / T1548.003 | login + sudo to root | sshd/PAM auth log | 5501 / 5402 (built-in) | 3 | yes (baseline) |
Why FIM, not auditd
The persistence detections key off file integrity monitoring, not syscall auditing - and that is a deliberate choice, not a shortcut. FIM is Wazuh-native and works everywhere, including containers and hardened hosts where the kernel audit framework is not available to you. Auditd execve rules, by contrast, silently do nothing on a host that boots with audit disabled - which is the common case inside an LXC container, where the audit subsystem is namespaced away and rules load without error but never fire.
The auditd ruleset that would be layered on a host with working kernel auditing is included for completeness in agent/auditd-purple.rules - it watches execve, credential files, and the persistence directories - but the validated detections above do not depend on it:
-a always,exit -F arch=b64 -S execve -S execveat -k exec
-a always,exit -F arch=b32 -S execve -S execveat -k exec
-w /etc/shadow -p r -k cred_access
-w /etc/passwd -p wa -k passwd_change
-w /etc/sudoers -p wa -k sudoers_change
-w /etc/cron.d -p wa -k cron_persist
-w /etc/systemd/system -p wa -k systemd_persistThe honest consequence is a noted coverage gap: execve-based detections such as reverse-shell command lines (T1059.004) need that host syscall auditing the target kernel did not provide. The FIM-based persistence detections are independent of it, which is exactly why they survive the environments where auditd does not.
Running it
On the endpoint - an enrolled Wazuh agent with the FIM config from agent/ applied - the entire technique set runs from one script. It logs each step with a timestamp and supports a --cleanup flag that removes the cron job, systemd unit, dropped binary, and the planted SSH key:
sudo bash atomics/run_atomics.sh # execute the technique set
sudo bash atomics/run_atomics.sh --cleanup # execute, then revert all artifactsThe brute-force step loops 18 password-auth attempts against localhost with pubkey auth forced off, so each one lands in the sshd auth log as an invalid user:
for i in $(seq 1 18); do
ssh -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=no \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
"evil_user_${i}@127.0.0.1" true 2>/dev/null
doneThen in the Wazuh dashboard, filter Threat Hunting → Events to confirm the hits:
rule.id:(100410 or 100411 or 100412 or 100413 or 5712)Caldera, for graph-based emulation, is optional and comes up alongside:
cd caldera && docker compose up -d # http://localhost:8888
Detection-as-code
This lab is one half of a two-repo workflow. Detections are authored once as Sigma in a separate detection-as-code repository and compiled to each target SIEM; the Wazuh-native versions of the persistence rules proven here are the compiled output. The endpoint itself is the same instrumented host stood up in a companion SOC-automation lab, so the agent, indexer, and dashboard are not bespoke to this project - they are the standing detection stack this work validates against.
Keeping emulation and authoring in separate repos enforces the discipline: a rule does not get marked validated here until an atomic in run_atomics.sh has demonstrably triggered it on the live agent. The matrix is regenerated from real runs, and the screenshot in the repo is the evidence, not a mockup.
What it proves
Severity is the point as much as coverage is. The persistence detections fire at level 10–12 because they would page an analyst, while login and sudo events sit at level 3 as context, not alerts. A detection that fires at the wrong severity is as useless as one that does not fire at all.
- Detections are tested, not assumed. Each custom rule has a corresponding atomic that demonstrably triggers it on the live agent - the matrix reflects an actual run, not an intended design.
- Gaps are noted honestly. Execve-based detections such as reverse-shell command lines (T1059.004) require host syscall auditing the target kernel did not provide; that limitation is documented rather than papered over, and the FIM persistence path is independent of it.
- Telemetry choice is justified. FIM was chosen over auditd specifically because it survives containers and hardened hosts where kernel auditing silently fails - a deliberate engineering decision with a stated tradeoff.
- It plugs into a real workflow. The rules proven here are the compiled output of a Sigma-first detection-as-code pipeline, validated against a standing Wazuh stack rather than a throwaway.
