The obvious Linux credential-theft rule raised zero alerts. Here's why, and what fired instead

For eight days our Linux lab victim let an attacker read credential files without a single relevant alert. We detonated the two stock Atomic Red Team tests for unsecured credentials six times between 2026-10-20 and 2026-10-28, and every run graded partially detected: the SIEM saw the attack start, and nothing about what it actually did.
When we wrote the "obvious" rule to fix it, it raised zero alerts. This post is about why, and about the two rules that do fire now, 100430 (T1552.001, credentials in files) and 100431 (T1552.003, shell history).
What the lab saw before
Every one of the six runs produced the same five alerts and nothing else:
| Rule | What it is |
|---|---|
| 100101 | Our lab marker: "a detonation started" |
| 5402 | sudo to root |
| 5501 / 5502 | PAM session opened / closed |
That is the harness becoming root. The part where the attack reads a secret left no trace at all. The reason was simple once we looked: lab/config/wazuh/agent/linux-audit.rules only recorded execve for bash, sh, python3, curl and wget, and only watched /etc/shadow and /etc/ssh/ for file access. The Atomic tests run inside pwsh, read files in home directories, and use cat, grep and find. auditd was never asked to look. This was a collection gap, not a rule gap: no rule can match an event that was never logged.
The obvious fix, and why it was silent
The textbook answer is a file watch. Tell auditd to log reads of the credential file, then alert on that:
-w /root/.bash_history -p r -k lab_cred_access
<rule id="100430" level="7">
<if_group>audit</if_group>
<field name="audit.key">^lab_cred_access$</field>
<field name="audit.file.name" type="pcre2">(?i)\.bash_history$</field>
</rule>
We deployed it and re-detonated (run-2026-10-28-048). It raised zero alerts.
The watch itself is ordinary auditd. The problem is on the Wazuh side: the bundled audit ruleset that our custom rules hang off (if_group audit) raises alerts for execve records, not for the plain file-access records a -w ... -p r watch produces. So the read never becomes a parent alert that a child rule can match. A rule written against it is structurally dead on a stock Wazuh install, and nothing tells you so. There is no error and no warning. It just never fires.
If you take one thing from this post: a rule that has never fired is not a detection, it is a hypothesis. We only caught this because the lab grades every rule on a real detonation.
We had a second problem in the same rule. It chased .bash_history, which is ATT&CK T1552.003 (shell history), but it was tagged T1552.001 (credentials in files). Our batch planner had paired the two techniques with each other's tests, and our first gap write-up carried the mix-up forward. Worth checking in your own content: the two sub-techniques sit next to each other and are easy to swap.
What fires now: key on the reader, not the file
Since Wazuh alerts on execve, we moved the detection to the process that does the reading. Two changes:
1. Collect it. Add execve watches for the readers the attacks use (PR #145):
-a always,exit -F arch=b64 -S execve -F exe=/usr/bin/cat -k lab_exec
-a always,exit -F arch=b64 -S execve -F exe=/usr/bin/grep -k lab_exec
-a always,exit -F arch=b64 -S execve -F exe=/usr/bin/find -k lab_exec
2. Match the reader plus a credential path in its arguments. The Wazuh decoder exposes the binary as audit.command and each argument as audit.execve.a1, a2 and so on. Rule 100430 (PRs #151 and #154) fires when a shell reader is run on a file that looks like a secret:
<rule id="100430" level="6">
<if_group>audit</if_group>
<field name="audit.key">^lab_exec$</field>
<field name="audit.command" type="pcre2">(?i)^(?:cat|less|more|tail|head|strings|grep|nl)$</field>
<field name="audit.execve.a1" type="pcre2">(?i)(?:\.drone\.yml$|\.aws[/\\]|(?:^|[/\\])\.env$|(?:^|[/\\])credentials$|id_rsa|\.npmrc$|\.git-credentials$|\.kube[/\\]config|\.docker[/\\]config|terraform\.tfstate)</field>
<mitre><id>T1552.001</id></mitre>
</rule>
Rule 100431 is simpler: grep or find executed under the same lab_exec key, level 5, tagged T1552.003.
The proof runs
| Run | Technique | Atomic test | Result | Rule | Latency | Alerts in window |
|---|---|---|---|---|---|---|
| run-2026-10-28-050 | T1552.003 | Search Through Bash History (3cfde62b) | detected | 100431 | 3 s | 6 (1 expected) |
| run-2026-10-28-057 | T1552.001 | Access Drone CI Config File (a11f22fb) | detected | 100430 | 1 s | 9 (1 expected) |
In run 057 the alert came from cat /home/<user>/.drone.yml. The file path landed in audit.execve.a1, exactly where the rule looks, so we didn't need to tune argument positions.
One honest detail about 057. The Drone CI test runs find / -name .drone.yml -exec cat and has no setup step, so it only reads something if a .drone.yml already exists. The earlier runs found nothing because there was nothing to find. For the proof run we staged a harmless .drone.yml in the victim's home directory first. That's a fair test of the rule (a real attacker only bothers when the file is there), but it means the three earlier Drone CI runs graded "partial" were partly a test with no target, not only a blind SIEM. The fixture was removed when both victims were reverted to their clean snapshots on 2026-10-30.
Where these rules are weak
We'd rather say this than have you find it.
100431is close to "grep ran". It matches anygreporfindunder thelab_execkey, with no look at the arguments. In the 30 minutes before run 057 it fired 41 times with no attack running, and it made up most of that window's baseline (41 of 54 rule hits). Before run 050 it fired once. We haven't attributed those 41 hits yet. Level 5 keeps it out of anyone's pager, but as written it is a telemetry tripwire, not a credential-search detection. The next step is matching the search pattern in the arguments (pass,secret,token,-p) once we confirm which argument index the decoder puts it in.100430can only see what auditd records. The rule listsless,more,tail,head,stringsandnl, but the audit rules only logexecveforcat,grepandfind. Soless ~/.aws/credentialsis invisible today. Adding those binaries to theexecveset closes it, at some cost in volume.- Only the first argument is checked.
cat ~/.envmatches.cat -n ~/.envputs the path ina2and does not. A real attacker using a flag, orcp,python3 -c open(...), or a shell redirect, walks around it. - Arguments with spaces or special characters are lost. auditd hex-encodes such tokens and the Wazuh decoder drops them, so a credential path with a space in it won't match anything.
- This is one lab VM. The detection is proven on one host, with one test each, run as root. It is not a false-positive rate for a fleet of developer machines, where
cat .envis a normal Tuesday.
What we'd tell a defender
- Fire-test every rule. A Wazuh rule built on
-w ... -p rfile watches can look correct, pass XML validation, deploy cleanly and never fire. Run the attack and check for the alert. - On Wazuh, detect credential reads at
execve. Watch the reader binaries, then match the credential path in the decoded arguments (audit.execve.aN). - Watch every reader you name in the rule. A rule that lists
lesswhile auditd only recordscatis promising coverage it doesn't have. - Measure the baseline before you trust a broad rule. Our
grep/findrule fires dozens of times an hour on a quiet host. Count before you rely on it.
Attack record: unsecured-credentials. Grounded in the committed bundles lab/results/run-2026-10-28-050/run.json and lab/results/run-2026-10-28-057/run.json, the six earlier partial runs on the same two tests (run-2026-10-20-003/004, run-2026-10-27-029/030, run-2026-10-28-023/024), rules 100430 and 100431 in lab/config/wazuh/rules/local_rules.xml, and lab/config/wazuh/agent/linux-audit.rules. Run 048 (the silent file-watch rule) raised no alerts and is recorded in the comment above rule 100430.