CoursesAdvanced Linux securityDetections that survive contact

Detections that survive contact

Behavior over strings, tests over hope.

Advanced14 min · lesson 13 of 17

A wanted poster that says the suspect wears a red baseball cap is useless the moment he takes the cap off. A poster that says he comes in through the roof, cuts the power to the alarm panel, and leaves through the loading dock describes what he does, and he cannot stop doing those things without giving up the whole job. Detection engineering splits the same way. You can alert on what an attacker's tool happens to be right now (its file hash, its filename, the address it calls home to) or on what the attacker has to do to win (spawn a shell from your web server, read a private key, write to a startup path). One set of facts changes in seconds. The other costs real effort to change, and some of it cannot change at all without abandoning the technique.

A detection survives contact when a live intruder walks into it and still trips the wire, even after they recompile their malware, rename their binaries, and rotate their servers. That is the bar. Anything below it is a detection that looked fine in a slide deck and went dark the first time it mattered.

Cheap strings, expensive behavior

Security engineer David Bianco drew this idea as the Pyramid of Pain. At the bottom sit the signals that cost an attacker almost nothing to change: file hashes, IP addresses (the numeric address of a machine on the network), domain names. These are indicators of compromise (IOCs, the specific leftovers a known bad thing drops). Higher up sit the attacker's tools, which take more effort to swap. At the very top sit tactics, techniques, and procedures (TTPs, the how of an attack rather than the what). The name of the pyramid is the whole point. When your rule forces the attacker to change something near the top, you have caused them real pain. When it only forces them to recompile, you have caused them a ten-second wait.

What it costs the attacker to slip your rule
Rotated in seconds
File hash
recompile, new hash
Filename / path
one mv command
IP or domain
rent a fresh server
Costs an afternoon
Specific tool
swap netcat for another shell tool
Signature string
edit the malware source, rebuild
Abandon the technique
Service spawns a shell
the foothold itself
Odd process reads a private key
the credential theft itself
Write to a startup / preload path
the persistence itself
Left to right, each column is more expensive to change. Rules built on the right-hand column survive contact; rules built on the left go stale before they ship.

The strongest pull in this job is to write a rule for whatever is in the latest threat report: this hash, this filename, this command-and-control domain. It feels productive. It ships fast. And it is stale before the ink dries, because the next campaign uses a fresh build on fresh infrastructure. Spend your effort one level up. Ask what the technique fundamentally has to do, then write the rule there.

Watch what a process does, not what it is called

Take a real persistence trick: dynamic linker hijacking (catalogued as technique T1574.006 in MITRE ATT&CK, a public library of real-world attacker techniques run by the non-profit MITRE; ATT&CK stands for Adversarial Tactics, Techniques, and Common Knowledge). The dynamic linker is the part of the system that loads shared libraries into a program as it starts. The file /etc/ld.so.preload lists libraries to force-load into every program that runs, including sshd, sudo, and cron. Drop your library there and your code runs inside everything. You will never catch this by hashing the library, because the attacker builds a fresh one per host. You catch it by watching the file that has to be touched.

That watching is the job of the Linux audit system. The matching happens inside the kernel (the core of the operating system that talks to the hardware and controls every privileged action), and a userspace helper called auditd (the audit daemon) writes down what the kernel reports: file access, system calls, and the like. A system call is the request a program makes to the kernel to do privileged work, such as opening a file or launching another process. Put a watch on the preload file and every write shows up with the who and the how attached.

~/secopslog — bash
$ # alert on any write or attribute change to the linker preload file sudo auditctl -w /etc/ld.so.preload -p wa -k linker_hijack sudo auditctl -l
-w /etc/ld.so.preload -p wa -k linker_hijack

Read the flags plainly. The -w names the path to watch. The -p wa says fire on writes (w) and attribute changes (a) such as permission or owner edits. The -k tags every matching event with the key linker_hijack so you can find it later. Now reproduce the technique the way a defender should, on a lab box, with a harmless library path that does not exist.

~/secopslog — bash
$ # benign reproduction: append a bogus library path echo '/tmp/.cache/libskew.so' | sudo tee -a /etc/ld.so.preload
/tmp/.cache/libskew.so
$ sudo ausearch -k linker_hijack -i | tail -n 20
---- type=PROCTITLE msg=audit(07/17/2026 14:22:41.310:918) : proctitle=tee -a /etc/ld.so.preload type=PATH msg=audit(07/17/2026 14:22:41.310:918) : item=1 name=/etc/ld.so.preload inode=262541 dev=08:02 mode=file,644 ouid=root ogid=root nametype=NORMAL type=PATH msg=audit(07/17/2026 14:22:41.310:918) : item=0 name=/etc/ inode=131073 dev=08:02 mode=dir,755 ouid=root ogid=root nametype=PARENT type=CWD msg=audit(07/17/2026 14:22:41.310:918) : cwd=/home/alice type=SYSCALL msg=audit(07/17/2026 14:22:41.310:918) : arch=x86_64 syscall=openat success=yes exit=3 a2=0x441 items=2 ppid=51190 pid=51196 auid=alice uid=root gid=root euid=root tty=pts0 ses=3 comm=tee exe=/usr/bin/tee subj=unconfined key=linker_hijack

The SYSCALL line is where the value lives. comm=tee and exe=/usr/bin/tee tell you what ran. uid=root tells you it ran as root. The one to circle is auid=alice. That is the login UID (auid, the user ID recorded when the human first logged in), and it sticks to every action that person takes even after they switch to root with sudo. uid says root did it; auid says alice became root and did it. That single field turns an anonymous root event into an accountable one. One caveat: auditctl -w is loaded into the running kernel and vanishes on reboot. To make the watch survive a restart, drop it in a rules file that augenrules loads at boot.

/etc/audit/rules.d/10-linker.rules
-w /etc/ld.so.preload -p wa -k linker_hijack
-w /etc/ld.so.conf -p wa -k linker_hijack
-w /etc/ld.so.conf.d/ -p wa -k linker_hijack
~/secopslog — bash
$ sudo augenrules --load && sudo auditctl -s | grep enabled
enabled 1
A process name is a string too
The friendly name you see (comm, the short kernel label for a program, capped at 15 characters, and argv[0], the name a program was invoked as) are both under the attacker's control. A program can rename itself while it runs by calling prctl (a system call a process uses to change its own settings) with the PR_SET_NAME option, or launch a child under a faked argv[0], so comm=nginx can be a lie. Anchor on the things the attacker cannot rename away: the parent-and-child chain (a database daemon should never be the parent of bash), the real executable path on disk, and the system calls actually made. Falco's proc.pname field and the audit ppid give you that lineage. Use it.

A rule you have never fired is a hope

A detection you have never triggered on purpose is a smoke detector you have never held a match under. You do not know if the battery is in, if the sensor points at the kitchen, or if it screams at burnt toast every single morning. You find out during the fire, which is the worst possible time to learn. Treat detections like code. For every rule, keep a safe way to reproduce the behavior it should catch, fire it, and confirm the alert lands. Then confirm that normal activity stays quiet.

Here is the same idea with Falco (an open-source runtime security tool that reads the stream of system calls through a kernel module or an eBPF probe, which is extended Berkeley Packet Filter, a way to run small sandboxed programs inside the kernel). Rules are written in YAML (a plain-text configuration format). This one alerts when a private SSH key, the secret half of a Secure Shell login pair, is read by a process that has no business reading it.

/etc/falco/rules.d/local_rules.yaml
- rule: SSH Private Key Read By Unexpected Process
desc: >
A private SSH key was opened for reading by a process outside normal
SSH or git activity. Common in credential theft after a foothold.
condition: >
open_read
and (fd.name endswith "/id_rsa" or fd.name endswith "/id_ed25519")
and not proc.name in (ssh, sshd, scp, ssh-agent, ssh-keygen, git, sftp-server)
output: >
SSH private key read (file=%fd.name reader=%proc.name
cmd=%proc.cmdline user=%user.name login_uid=%user.loginuid
pid=%proc.pid ppid=%proc.ppid)
priority: WARNING
tags: [host, filesystem, credential-access, T1552.004]

Read the condition like a sentence. open_read is a built-in Falco shorthand for a file being opened to read. fd.name endswith checks the path of that file. The not proc.name in (...) line is an allowlist of the tools that legitimately read keys, so ssh and git stay silent. The output line pulls out the file, the reader, the full command, and login_uid (the same accountable login identity you saw as auid in auditd). The tag T1552.004 is the ATT&CK ID for stealing private keys, which matters in a moment. Load it and prove it works.

~/secopslog — bash
$ sudo systemctl restart falco systemctl is-active falco
active
$ # benign reproduction of credential theft (T1552.004): read your own key cat ~/.ssh/id_ed25519 > /dev/null sudo journalctl -u falco -n 3 --no-pager
Jul 17 14:31:09 web01 falco[52210]: 14:31:09.882431019: Warning SSH private key read (file=/home/alice/.ssh/id_ed25519 reader=cat cmd=cat /home/alice/.ssh/id_ed25519 user=alice login_uid=1000 pid=52240 ppid=51102)

It fired, and it caught the behavior, not a name. Swap cat for python, curl, or a renamed binary and the alert still lands, because the durable fact is a process outside the allowlist reading a key file. Now test the other side, which people skip: run git and ssh against a key and confirm Falco stays quiet, or you have built an alarm that cries every deploy. Those are the two failure modes you are hunting. The rule that never fires gives you a false sense of coverage. The rule that fires constantly buries the real alert under noise until nobody reads either. You want to find both in a test, not in an incident. Atomic Red Team, a free library from Red Canary, packages hundreds of these small reproductions, each mapped to an ATT&CK technique and each shipping with a cleanup step, so you can run the benign version of a technique with one command and watch what lights up. Run those in a lab or a throwaway host, never on the box you are protecting, because some of them make real changes.

Coverage is a portfolio, not a pile

A dozen good rules are not coverage. Coverage is knowing which techniques you can see and, more honestly, which you cannot. Because every rule carries its ATT&CK tag, you can inventory what you watch straight from the rule files.

~/secopslog — bash
$ grep -rhoE 'T[0-9]{4}(\.[0-9]{3})?' /etc/falco/rules.d/ | sort -u
T1053.003 T1059.004 T1543.002 T1552.004 T1574.006

Now hold that list against the techniques that actually threaten your environment. Free tools help here: the ATT&CK Navigator colors the matrix so gaps are visible at a glance, and DeTT&CT scores how good each detection really is. This box watches cron jobs, unix shells, systemd service persistence, key theft, and linker hijacking. It watches nothing for T1548 (abusing sudo or setuid to escalate privilege). That is a gap, and because you can name it, it is a decision you can argue about and prioritize. The gaps that hurt are the ones nobody wrote down. A curated portfolio has known holes; a pile of rules has surprises.

Quick check
01You ship a Falco rule that fires when proc.name is nc. A week later the same intruder lands a reverse shell on that host and nothing alerts. What happened, and what do you replace the rule with?
Incorrect — Watching your own sensor is worth doing, but a dead agent would have gone silent for everything, not just this one technique. The rule matched a string the intruder edited.
Incorrect — Priority is only the severity label Falco stamps on an event it already decided to emit. It has no say in whether the condition matched at all.
Correct — A process name sits near the bottom of the pyramid, so changing it costs seconds. The pairing of an outbound connection with a shell is what the technique has to keep doing.
Incorrect — A hash is the cheapest signal on the whole pyramid to change. It is stale as soon as the next build finishes, which is the trap this lesson keeps pointing at.
02The audit record for a write to /etc/ld.so.preload shows uid=root and auid=alice on the SYSCALL line. A colleague asks why two identity fields exist. What does auid give you that uid does not?
Correct — uid=root tells you a root process did it. auid=alice tells you which human became root, and that is the field you can take into a conversation about responsibility.
Incorrect — The daemon's own identity is not what lands in that field. auid belongs to the person whose login session the action traces back to.
Incorrect — Ownership of the touched file shows up as ouid and ogid on the PATH line of the record. auid is on the SYSCALL line and describes a person, not a file.
Incorrect — It works the other way round. auid exists to tie an event back to a human login, which is exactly what a purely automated job tends to lack.
03Your 'SSH Private Key Read By Unexpected Process' rule alerts the first time you cat a key, and the lesson tells you that you are only half finished. Which test finishes the job?
Incorrect — Repeating a test that already passed teaches you nothing new. It never touches the allowlist or the stay-quiet-on-normal-work side of the rule.
Incorrect — Broadening coverage is a change to the rule, not a test of it, and the noise question stays unanswered. The condition already names id_rsa and id_ed25519.
Incorrect — Falco reads its rules from files, so there is nothing to bake in, and proving a rule loads is a long way from proving it behaves.
Correct — There are two ways a rule fails: it never fires, or it fires all day until nobody reads it. Your cat test hunted the first, this one hunts the second.

Pick one technique you claim to detect. Open a lab shell, run its benign form, and watch. If no alert lands, you did not have a detection. You had a line in a spreadsheet that said you did.

Try this

Work through “Coverage is a portfolio, not a pile” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a process name is a string too. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related