Detections that survive contact

Behaviour over indicators, and testing rules.

Advanced14 min · lesson 11 of 15

A detection is worth building only if it still fires after the attacker changes their tools. Most do not. A rule that names a file, a hash or an address goes dark the moment the intruder renames a binary or rents a new server, which costs them seconds. A rule keyed on what the technique has to do (run a program out of a scratch directory, read a private key, write to a startup path) costs them far more, because they cannot drop the action without giving up the goal. In this lesson you write two audit rules for the same tool, one keyed on its path and one on its behaviour, watch the first go blind when the tool is renamed while the second keeps catching it, and then measure each rule against ordinary admin work so you ship the one that is both durable and quiet. auditd is the collector here; it is not installed on a default Ubuntu server (linux-det/threatmodel), and the rule grammar and the auid login-identity field belong to linux-det/auditpipe, so they are used here without being re-taught.

The cheap signal and the expensive one

Security engineer David Bianco drew this trade-off as the Pyramid of Pain. At the base sit the indicators an attacker changes almost for free: hash values (trivial), IP addresses (easy), domain names (simple). In the middle sit network and host artefacts, such as a distinctive file name or path, which are annoying to change, and above them the attacker's tools, which are challenging to replace. At the top sit tactics, techniques and procedures, the way the intrusion is carried out, which are tough to change without abandoning the attempt. The pyramid tells you where to spend effort: a rule built on the base is stale before it ships, while a rule built near the top keeps working through a rename, a rebuild and a new server.

What it costs the attacker to slip your rule
Cheap to change
File hash
recompile, or change one byte
IP or domain
rent another
File name or path
one cp or mv
Expensive to change
Their tooling
swap for another
Host behaviour
exec from /tmp, read a key
The technique itself
cannot be skipped
Rules built on the right-hand column survive the changes the left-hand column makes for free.

Copying a legitimate utility to a new name or location to slip past monitoring is a technique of its own: T1036.003 (Masquerading: Rename Legitimate Utilities) in MITRE ATT&CK, the public catalogue of attacker behaviour. A rule that watches for the name nc or the path /usr/bin/socat is defeated by exactly that move, so write the rule against the move instead.

Phase one: a rule keyed on the path

Start the way an indicator feed would have you start: one audit rule for one executable, identified by its exact path. The rules file also carries a collection rule that records every program started from a login session; it is there only so you can measure noise later. -F exe= matches the executable's path, -F perm=x selects execution, and arch=b64 scopes the rule to 64-bit system calls. On an x86_64 server that can also run 32-bit programs, a 32-bit execve does not match a b64 rule, so ship each rule with an -F arch=b32 copy there (auditctl(8), arch=). The scratch directory was created first with sudo install -d -m 1777 /var/tmp/det-lab: world-writable with the sticky bit, like /var/tmp itself.

/etc/audit/rules.d/70-detections.rules
## Indicator: this one executable, by its exact path
-a always,exit -F arch=b64 -F exe=/var/tmp/det-lab/sysmon -F perm=x -F key=det_ioc
## Collection: every program started from a login session
-a always,exit -F arch=b64 -S execve -F auid>=1000 -F auid!=unset -F key=det_all

The lab loads the file with augenrules --load, the same way boot does, and auditctl -l shows what the kernel now holds. auditctl has added -S execve for the perm=x rule and prints auid!=unset as auid!=-1, its numeric form.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo auditctl -l | grep det_
-a always,exit -F arch=b64 -S execve -F exe=/var/tmp/det-lab/sysmon -F perm=x -F key=det_ioc -a always,exit -F arch=b64 -S execve -F auid>=1000 -F auid!=-1 -F key=det_all

Now stand in for a foothold. An unprivileged account drops a harmless tool into the world-writable scratch directory and runs it. The tool is a copy of GNU id (/usr/bin/gnuid on Ubuntu 26.04, a standalone binary; the default id is part of the uutils multi-call binary, which refuses to run under a name it does not recognise). It prints one line and does nothing else, but to the kernel it is a program executed from /var/tmp.

deploy@web01 · Ubuntu 26.04 LTS
$ cp /usr/bin/gnuid /var/tmp/det-lab/sysmon /var/tmp/det-lab/sysmon
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),4(adm),27(sudo)
$ sudo ausearch -k det_ioc -ts 09/27/26 08:41:50 --format text --input-logs | grep executed
At 08:41:50 09/27/26 deploy successfully executed /var/tmp/det-lab/sysmon

The indicator rule caught it. ausearch --format text turns the raw record into one readable sentence: who executed what, and when. Now do the thing that costs the attacker nothing, copy the same bytes to a new name and run them, then ask the rule again.

deploy@web01 · Ubuntu 26.04 LTS
$ cp /var/tmp/det-lab/sysmon /var/tmp/det-lab/kswapd0 /var/tmp/det-lab/kswapd0
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),4(adm),27(sudo)
$ sudo ausearch -k det_ioc -ts 09/27/26 08:41:50 --format text --input-logs | grep executed
At 08:41:50 09/27/26 deploy successfully executed /var/tmp/det-lab/sysmon

The output has not changed. The rule still shows only the sysmon run; the kswapd0 execution, identical bytes doing the identical thing a second later, is invisible to it because the rule names a path and the path changed. (The name kswapd0 imitates a kernel thread, which is a common disguise.) The execution was recorded by the collection rule, so the evidence exists; the detection simply did not look for it.

Phase two: a rule keyed on the behaviour

Replace the indicator with a rule about the behaviour: any program executed from under the scratch directory, whatever it is called. -F dir= puts a recursive watch on a directory tree. A foothold has to stage its tools somewhere it can write, and /var/tmp, /tmp and /dev/shm are the usual places; the lab watches its own subdirectory so the transcript stays reproducible, while a production rule would watch all three.

/etc/audit/rules.d/70-detections.rules
## Behaviour: any execution from the world-writable scratch tree, whatever the file is called
-a always,exit -F arch=b64 -F dir=/var/tmp/det-lab -F perm=x -F key=det_behav
## Collection: every program started from a login session
-a always,exit -F arch=b64 -S execve -F auid>=1000 -F auid!=unset -F key=det_all

The two rules are tested one at a time for a reason worth knowing. auditd writes each event once, under the key of the first rule that matches (auditctl(8): "the event triggers on the first matching rule"). With both the path rule and the directory rule loaded, the sysmon run would be filed under whichever came first and the other rule would look blind. Rule order is part of the design, which is also why suppression (never) rules go at the top of a rules file.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo auditctl -l | grep det_
-a always,exit -F arch=b64 -S execve -F dir=/var/tmp/det-lab -F perm=x -F key=det_behav -a always,exit -F arch=b64 -S execve -F auid>=1000 -F auid!=-1 -F key=det_all
$ /var/tmp/det-lab/sysmon /var/tmp/det-lab/kswapd0
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),4(adm),27(sudo) uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),4(adm),27(sudo)
$ sudo ausearch -k det_behav -ts 09/27/26 08:41:53 --format text --input-logs | grep executed
At 08:41:53 09/27/26 deploy successfully executed /var/tmp/det-lab/sysmon At 08:41:53 09/27/26 deploy successfully executed /var/tmp/det-lab/kswapd0

Both names are caught. The rule does not care what the file is called because it keys on where it runs from, and no rename moves the program out of that directory; to slip the rule the attacker has to stage somewhere else, and the callout below lists where. The raw record behind the second hit shows the fields a detection should rely on.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo ausearch -k det_behav -sc execve -ts 09/27/26 08:41:53 -i --input-logs | grep -E 'type=SYSCALL' | tail -1
type=SYSCALL msg=audit(09/27/26 08:41:53.797:11626) : arch=aarch64 syscall=execve success=yes exit=0 a0=0xb88406c55a80 a1=0xb88406c54f90 a2=0xb88406c60840 a3=0xf1744de7dc90 items=2 ppid=88547 pid=88548 auid=deploy uid=deploy gid=deploy euid=deploy suid=deploy fsuid=deploy egid=deploy sgid=deploy fsgid=deploy tty=(none) ses=50 comm=kswapd0 exe=/var/tmp/det-lab/kswapd0 subj=unconfined key=det_behav

exe=/var/tmp/det-lab/kswapd0 is the path the program ran from and syscall=execve is the execution; the lab is aarch64, and an x86_64 server records arch=x86_64 in this interpreted view (c000003e in the raw log). auid=deploy ties the event to the login behind it. comm=kswapd0 is the least trustworthy field in the line: the kernel sets it at execve from the name of the file that was executed, so a rename changes it, and a running program can change it again with prctl(PR_SET_NAME). argv[0], which audit shows in the PROCTITLE record, is whatever the launcher chose. Either way comm=nginx can be a lie. Anchor on what the attacker cannot choose freely: the path a file runs from, the parent-and-child chain (a database daemon should never be the parent of a shell), and the system calls actually made.

A behaviour rule is only as good as the behaviour you picked
Execution from a scratch directory is durable because a foothold needs somewhere writable to stage tools, but it is not universal. An attacker with root can stage in a packaged path. A service account can also write to its own home, the application's upload or cache directories, /run/user/<uid> and /var/lib/<app>, which this rule does not watch. A script fed to an interpreter (sh /var/tmp/x.sh) executes /usr/bin/sh, not the file in /var/tmp. A payload loaded into an anonymous memory file (memfd_create(2), then execveat(2)) runs with no file in any directory; its /proc/<pid>/exe reads /memfd:... (deleted), which the /proc query in linux-det/hunting catches. The preventive partner of this rule is a noexec mount on the scratch directories (Ubuntu 26.04 mounts /tmp nosuid,nodev without noexec); it blocks direct execution, not the interpreter or the memfd route. Treat detection as a portfolio. Pair this rule with rules for the other moves techniques force, such as reading private keys, writing to /etc/systemd/system or the linker preload file (linux-det/auditpipe owns those watches), or a shell whose parent is a network service, and write down which techniques you still cannot see.

A rule you have not measured is a guess

A durable rule is useless if it drowns you. The opposite failure to "never fires" is "fires all day until nobody reads it", and you only find out which one you built by measuring. Run ordinary admin work with the behaviour rule loaded, then count each key since the rule was loaded.

deploy@web01 · Ubuntu 26.04 LTS
$ id >/dev/null ls -la /etc >/dev/null journalctl -n 20 --no-pager >/dev/null getent hosts localhost >/dev/null echo done
done
$ sudo aureport -k --summary -ts 09/27/26 08:41:53 --input-logs | grep -E 'det_all|det_behav'
171 det_all 2 det_behav

The collection rule, which records every program a logged-in user starts, logged over a hundred events from a few seconds of routine work: that is what an "alert on every execution" rule would page you with. The behaviour rule logged two, the sysmon and kswapd0 runs, and nothing from the admin session, because nobody on this host legitimately runs programs out of that directory. Low volume on normal activity is what makes a rule shippable. If det_behav had fired on routine work, you would have found a real workflow that stages in a scratch directory, and you would fix the workflow or add a narrow exception before the rule paged anyone at 2 a.m. Run the measurement on a host doing the work you are protecting, not an idle one, because the false positives that matter come from the legitimate activity a rule has to live alongside.

Testing a detection before you trust it
Reproduce
run the behaviour safely
a renamed copy of a harmless tool
confirm it fires
read the record back
Measure the noise
run normal admin work
the activity it must ignore
count the hits
quiet on normal work = shippable
A tested detection has been proven twice: it fires on the behaviour and stays silent on ordinary work.

Try this

On a lab host with auditd, repeat both phases with your own scratch directory (sudo install -d -m 1777 /var/tmp/lab). Put each rule in /etc/audit/rules.d/70-lab.rules and load it with sudo augenrules --load, as the lesson did. Load only -a always,exit -F arch=b64 -F exe=/var/tmp/lab/tool -F perm=x -F key=byname, copy /usr/bin/gnuid to /var/tmp/lab/tool, run it, and confirm sudo ausearch -k byname --format text shows it. Copy it to /var/tmp/lab/kworkerd, run that, and confirm byname still shows only tool. Then replace the rule with -F dir=/var/tmp/lab -F perm=x -F key=bybehav, run both copies again, and confirm bybehav shows both. Finally run id, ls and systemctl status a few times and check with sudo aureport -k --summary -ts recent that bybehav did not grow. If you load both rules at once, expect the first one in the file to take every event it matches.

Takeaway

Key detections on what a technique cannot avoid doing, not on a name, hash or address it can change for free, and prove every rule twice before you trust it: fire it with a safe reproduction, and confirm it stays silent through ordinary admin work.

Quick check
01You ship an audit rule that fires when a program's executable path is exactly /var/tmp/.cache/miner. A week later the same foothold runs the identical binary as /var/tmp/.cache/kworkerd and nothing alerts. What is the durable fix?
Incorrect — You would be chasing names forever; the next run picks a third name in seconds, which is exactly the cost the pyramid says is trivial for the attacker.
Incorrect — Priority changes how an event is labelled, not whether the rule matched; the rule never matched the renamed path, so there was no event to prioritise.
Correct — A watch on -F dir=/var/tmp -F perm=x fires for any file executed there, so the rename that beat the path rule does not beat it, and staging in a writable directory is a move the foothold has to make.
Incorrect — A hash is the cheapest indicator on the pyramid to change; the attacker recompiles and it is stale, and it never survives even a rename of the same bytes.
02A colleague argues that keying detections on comm (the process name the kernel reports) is safe because it comes from the kernel, not from a filename on disk. Why is that reasoning wrong?
Correct — The file name and prctl are both in the attacker's hands, so comm=nginx can be a lie; anchor instead on the executable path, the parent-child chain and the syscalls made.
Incorrect — The 15-character cap is real but is not the point; even an untruncated comm is untrustworthy because the process can set it to anything.
Incorrect — comm is set for every process regardless of how it started; there is no systemd-only behaviour here.
Incorrect — comm describes the process itself, not its parent; the parent is a separate, and more trustworthy, signal via ppid.
03Your new behaviour rule fires correctly the first time you reproduce the attack in a lab. The lesson says you are only half done. What is the other half?
Incorrect — Repeating a test that already passed proves nothing new; it never touches the question of whether the rule is quiet on legitimate activity.
Incorrect — Broadening coverage is a change to the rule, not a test of it, and it leaves the noise question, the reason rules get ignored, entirely unanswered.
Incorrect — Where the rule file lives is an operational concern, not a test of the rule's behaviour, and it says nothing about false positives.
Correct — A rule fails in two ways, never firing or firing constantly, and your reproduction only tested the first; measuring it against normal activity tests the second.

Related