Investigating launchd Persistence on macOS
Find and analyze macOS persistence: LaunchAgents, LaunchDaemons, launchctl, Background Task Management (sfltool dumpbtm), cron, periodic and profiles.
When an investigator asks "how does this code keep coming back after a reboot?", the answer on macOS is almost always launchd. launchd is PID 1 on macOS: it starts system services, per-user agents and many third-party helpers from property list (plist) job definitions. Malware, adware, remote-access tools and legitimate enterprise agents all use the same mechanism, which makes launchd plists the first place to look for persistence and one of the richest sources of context about what an attacker intended to run, when and as whom. This article covers where launchd jobs live, which plist keys matter, how to query the running state with launchctl, how Background Task Management (BTM) changed login item forensics from macOS Ventura onward, and which non-launchd mechanisms still deserve a look.
Where launchd jobs live
launchd reads job definitions from a small set of directories. The distinction between agents and daemons is important: daemons run in the system context (as root unless a UserName key says otherwise) and do not need a logged-in user, while agents run in a user's session.
| Location | Type | Runs as | Who can write it |
|---|---|---|---|
~/Library/LaunchAgents | Per-user agent | That user, at login | The user (no admin rights needed) |
/Library/LaunchAgents | Agent for every user | Each logged-in user | Administrators (root) |
/Library/LaunchDaemons | System daemon | root by default | Administrators (root) |
/System/Library/LaunchAgents | Apple agent | Logged-in user | Apple only (sealed system volume) |
/System/Library/LaunchDaemons | Apple daemon | root | Apple only (sealed system volume) |
<App>.app/Contents/Library/LaunchAgents / LaunchDaemons | App-bundled job registered via SMAppService (macOS 13+) | Depends on type | The app developer |
Since macOS Big Sur, the /System hierarchy sits on the sealed system volume, so on a Mac running with Full Security it cannot be modified at runtime. Third-party persistence therefore lands in /Library or in the user's home. A per-user ~/Library/LaunchAgents plist is the most common choice for commodity malware because it requires no privilege escalation; a /Library/LaunchDaemons plist implies the actor obtained root, which is itself a finding.
Remember that each local user has their own ~/Library/LaunchAgents. During triage, enumerate every home directory under /Users, including accounts that are rarely used, and /var/root/Library/LaunchAgents for completeness.
Anatomy of a launchd plist
A launchd plist is a dictionary of keys. It may be stored in XML or binary form; plutil -p prints either format in a readable way without modifying the file.
plutil -p ~/Library/LaunchAgents/com.example.agent.plist
plutil -lint /Library/LaunchDaemons/*.plist
A generic example of what a suspicious per-user agent might look like:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.agent</string>
<key>ProgramArguments</key>
<array>
<string>/Users/analyst/Library/Application Support/.example/agent</string>
<string>--daemon</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StartInterval</key>
<integer>3600</integer>
</dict>
</plist>
The keys an investigator should read first:
| Key | Meaning | Investigative note |
|---|---|---|
Label | Unique job name | Often mimics Apple (com.apple.*) or a real vendor; compare with the file name and the binary |
Program / ProgramArguments | What executes | The first element is the binary or interpreter; look for /bin/sh -c, osascript, python3, curl piped to a shell |
RunAtLoad | Start when the job is loaded (login or boot) | The classic persistence switch |
KeepAlive | Restart the job if it exits | Explains why killing a process "does nothing" |
StartInterval / StartCalendarInterval | Periodic execution | Beaconing or scheduled re-infection |
WatchPaths / QueueDirectories | Trigger on file system changes | Less common, easy to overlook |
UserName | Account for a daemon | A daemon without it runs as root |
StandardOutPath / StandardErrorPath | Output redirection | Can point to log files containing useful output |
EnvironmentVariables | Environment passed to the job | Watch for DYLD_INSERT_LIBRARIES |
Disabled | Job disabled in the plist | The effective state can be overridden by launchd's own records |
Always follow the path in ProgramArguments. Hash the target, check its signature with codesign -dv --verbose=4 <path>, run spctl --assess --verbose <path> for a Gatekeeper opinion, and look for the com.apple.quarantine attribute with xattr -l. Hidden directories (.example), paths in /tmp, /Users/Shared or ~/Library/Application Support with vendor-looking names, and unsigned or ad hoc signed binaries are all worth prioritizing. See Quarantine Events and Gatekeeper for the download side of the story.
Querying the live state with launchctl
On a live system, the plist on disk only tells you what is configured. launchctl tells you what launchd has actually loaded.
# Legacy-style listing for the current context (PID, last exit status, label)
launchctl list
# Detailed view of the system domain and of a user's GUI domain (UID 501 here)
sudo launchctl print system
launchctl print gui/501
# Details of a single job: program, arguments, state, PID, runs, last exit code
launchctl print gui/501/com.example.agent
sudo launchctl print system/com.example.daemon
# Jobs that launchd considers disabled in a domain
sudo launchctl print-disabled system
launchctl print-disabled gui/501
launchctl print output includes the path of the plist the job was loaded from, the program and arguments, the current state and PID, and counters such as the number of runs. A job that is loaded but whose plist is gone from disk, or a job whose label does not match any file, is a strong lead. Treat launchctl as read-only during an investigation: bootout, unload or disable change evidence and should be reserved for containment, after documentation.
launchd keeps its own enable/disable overrides under /private/var/db/com.apple.xpc.launchd/ (for example disabled.plist and per-user variants on recent releases). Collect that directory in dead-box work; verify the exact file names on your target version.
Login items and Background Task Management
Login items used to live in com.apple.loginitems.plist (up to macOS 10.12) and later in ~/Library/Application Support/com.apple.backgroundtaskmanagementagent/backgrounditems.btm (10.13 High Sierra to 12 Monterey). macOS Ventura introduced Background Task Management (BTM), which tracks login items, LaunchAgents and LaunchDaemons in a single store and notifies the user with "Background Items Added" when something new registers. The items appear under System Settings > General > Login Items.
The BTM store is kept at /private/var/db/com.apple.backgroundtaskmanagement/BackgroundItems-v<N>.btm, where the version number has increased across releases. The file is a keyed archive and is not pleasant to read directly; Apple ships a command that dumps it:
sudo sfltool dumpbtm > btm_dump.txt
For each item the dump lists fields such as the UUID, name, developer name, type (login item, agent, daemon, app), disposition (enabled/disabled, allowed/disallowed, notified), identifier, URL of the plist or bundle, executable path, team identifier and the parent application. The BTM record can outlive the plist that created it, which makes the file valuable when an attacker has already cleaned up. Do not run sfltool resetbtm on evidence: it rebuilds the database.
BTM activity is also logged. The following query is a useful starting point on a live system or a collected .logarchive (see Unified Logs):
log show --info --predicate 'subsystem == "com.apple.backgroundtaskmanagement"' \
--start "2026-09-20 00:00:00" --end "2026-09-28 23:59:59"
For real-time detection, the Endpoint Security framework exposes BTM events on macOS 13 and later (ES_EVENT_TYPE_NOTIFY_BTM_LAUNCH_ITEM_ADD and the corresponding remove event), which EDR products use to alert on new persistence as it is registered.
Other persistence mechanisms worth checking
launchd dominates, but a complete persistence review covers the older and quieter paths as well.
- cron: macOS still ships
cron. User crontabs are stored under/private/var/at/tabs/(also reachable through/usr/lib/cron/tabs/).crontab -lshows the current user's table; collect the directory for all users. - periodic: scripts in
/etc/periodic/daily,weeklyandmonthly(with configuration in/etc/defaults/periodic.confand optional/etc/periodic.conf) run as root via launchd.periodicwas removed in macOS 15 Sequoia, so this applies to macOS 14 and earlier. Compare the directory contents with a clean system of the same version. - Login and logout hooks: the legacy
LoginHook/LogoutHookkeys in thecom.apple.loginwindowpreferences are deprecated but may still be honoured on some versions. Check/var/root/Library/Preferences/com.apple.loginwindow.plist. - emond: the Event Monitor daemon (rules in
/etc/emond.d/rules/) was a documented persistence trick on older macOS. It was removed as of macOS 13 Ventura, but it matters when examining older systems. - Configuration profiles: a malicious or unexpected profile can install certificates, proxies or managed login items. List them with
sudo profiles listandsudo profiles show -type configurationon a live system. - Shell startup files:
~/.zshrc,~/.zprofile,~/.zshenv,/etc/zshrcand their bash equivalents run whenever a shell starts, which is persistence for anyone using Terminal or for scripts launched through a shell. - Authorization plugins, system extensions and kernel extensions:
/Library/Security/SecurityAgentPlugins,systemextensionsctl list, and/Library/Extensionsare higher-effort but high-impact locations.
Triage checklist
| Step | Action | Tooling |
|---|---|---|
| 1 | Collect all launchd directories for every user and the system | Aftermath, mac_apt, UAC, or tar with Full Disk Access |
| 2 | Parse each plist; extract Label, ProgramArguments, RunAtLoad, KeepAlive, intervals | plutil -p, mac_apt AUTOSTART plugin |
| 3 | Hash and verify signatures of every referenced binary | shasum -a 256, codesign -dv, spctl --assess |
| 4 | Compare configured jobs with loaded jobs | launchctl print system, launchctl print gui/<uid> |
| 5 | Dump Background Task Management | sudo sfltool dumpbtm |
| 6 | Review cron, periodic, profiles, shell rc files | crontab -l, profiles list |
| 7 | Build a timeline around plist creation | stat, FSEvents, unified log |
For timeline work, the birth time of the plist is usually the best anchor (stat -f '%SB %Sm %N' <file>), but it can be manipulated. Correlate it with FSEvents records for the LaunchAgents directory, BTM log entries, quarantine events for the dropped binary, and APFS snapshot contents (see APFS snapshots and timestamps).
Pitfalls
- Legitimate noise: browsers, updaters, VPN clients, backup tools and MDM agents all install LaunchAgents and LaunchDaemons. Baseline against known-good software and the organisation's standard image rather than flagging every non-Apple label.
- Label spoofing: a
com.apple.prefix proves nothing. Apple jobs live under/System; an Apple-looking label in/Library/LaunchDaemonsis a red flag. - Interpreter indirection: when
ProgramArgumentsstarts with/bin/bash,/usr/bin/python3or/usr/bin/osascript, the interpreter's signature is Apple's. The payload is the script argument, which is what needs analysis. - Collection gaps: without Full Disk Access, collection tools may miss user directories or the BTM store. Record the privileges your collector ran with.
- Changing evidence:
launchctl bootout,sfltool resetbtmandtccutil-style resets alter state. Document first, contain second. - Version drift: BTM file versions,
sfltooloutput fields and launchd override locations vary between releases. Verify on your target version before relying on a specific field.
Frequently asked questions
Where does macOS malware most often persist?
The most common locations are launchd property lists in ~/Library/LaunchAgents, /Library/LaunchAgents and /Library/LaunchDaemons, followed by login items. On macOS Ventura and later, Background Task Management records these items and can be reviewed with sudo sfltool dumpbtm.
Can an attacker add a LaunchDaemon to /System/Library/LaunchDaemons?
Not on a normally configured modern Mac. Since macOS Big Sur, /System lives on the sealed, read-only system volume, so third-party persistence belongs in /Library or the user's Library. A modified /System would indicate a disabled or reduced security configuration and deserves separate investigation.
Does removing a launchd plist erase all evidence of persistence?
No. Background Task Management databases, unified log entries, FSEvents records, APFS snapshots and Time Machine backups can all retain traces of an item after its plist has been deleted, so check them before concluding that nothing was installed.