Zion Boggan

In-depth vulnerability research, detection engineering & applied cryptography.

● Open to security-research & detection roles
GitHub · LinkedIn · Email
← All work
ADVERSARY EMULATION

Purple-Team Lab

Adversary emulation that validates the detections instead of assuming they work.

Atomic Red TeamCalderaWazuh FIMMITRE ATT&CKAuditdDetection-as-Code
4
Custom Wazuh rules (100410-100413)
6
ATT&CK techniques validated
5
Detections firing at alert severity
12
Highest rule level (authorized_keys)
18
Invalid SSH logins driving brute-force rule 5712
6
FIM paths watched in real time
Emulated techniques detected in Wazuh: custom rules 100410-100413 plus the brute-force rule firing on the purple-target agent, each ATT&CK-tagged.
Emulated techniques detected in Wazuh: custom rules 100410-100413 plus the brute-force rule firing on the purple-target agent, each ATT&CK-tagged.

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-malware

MITRE 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.

TechniqueATT&CKAtomic actionTelemetryRuleLevelDetected
Brute ForceT111018 invalid SSH logins from one sourcesshd auth log5712 (built-in)10yes
Scheduled Task / CronT1053.003write job to /etc/cron.d/FIM (real-time)100410 (custom)10yes
Systemd ServiceT1543.002create unit in /etc/systemd/system/FIM (real-time)100411 (custom)10yes
SSH Authorized KeysT1098.004append key to ~/.ssh/authorized_keysFIM (real-time)100412 (custom)12yes
Create / Modify System ProcessT1543drop binary in /usr/local/bin/FIM (real-time)100413 (custom)10yes
Valid Accounts / SudoT1078 / T1548.003login + sudo to rootsshd/PAM auth log5501 / 5402 (built-in)3yes (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_persist

The 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 artifacts

The 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
done

Then 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.

Repository · github.com/zionsworking/purple-team-lab