Skip to content

Investigating launchd Persistence on macOS

Find and analyze macOS persistence: LaunchAgents, LaunchDaemons, launchctl, Background Task Management (sfltool dumpbtm), cron, periodic and profiles.

Published on 9 min read

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.

LocationTypeRuns asWho can write it
~/Library/LaunchAgentsPer-user agentThat user, at loginThe user (no admin rights needed)
/Library/LaunchAgentsAgent for every userEach logged-in userAdministrators (root)
/Library/LaunchDaemonsSystem daemonroot by defaultAdministrators (root)
/System/Library/LaunchAgentsApple agentLogged-in userApple only (sealed system volume)
/System/Library/LaunchDaemonsApple daemonrootApple only (sealed system volume)
<App>.app/Contents/Library/LaunchAgents / LaunchDaemonsApp-bundled job registered via SMAppService (macOS 13+)Depends on typeThe 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:

KeyMeaningInvestigative note
LabelUnique job nameOften mimics Apple (com.apple.*) or a real vendor; compare with the file name and the binary
Program / ProgramArgumentsWhat executesThe first element is the binary or interpreter; look for /bin/sh -c, osascript, python3, curl piped to a shell
RunAtLoadStart when the job is loaded (login or boot)The classic persistence switch
KeepAliveRestart the job if it exitsExplains why killing a process "does nothing"
StartInterval / StartCalendarIntervalPeriodic executionBeaconing or scheduled re-infection
WatchPaths / QueueDirectoriesTrigger on file system changesLess common, easy to overlook
UserNameAccount for a daemonA daemon without it runs as root
StandardOutPath / StandardErrorPathOutput redirectionCan point to log files containing useful output
EnvironmentVariablesEnvironment passed to the jobWatch for DYLD_INSERT_LIBRARIES
DisabledJob disabled in the plistThe 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 -l shows the current user's table; collect the directory for all users.
  • periodic: scripts in /etc/periodic/daily, weekly and monthly (with configuration in /etc/defaults/periodic.conf and optional /etc/periodic.conf) run as root via launchd. periodic was 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/LogoutHook keys in the com.apple.loginwindow preferences 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 list and sudo profiles show -type configuration on a live system.
  • Shell startup files: ~/.zshrc, ~/.zprofile, ~/.zshenv, /etc/zshrc and 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/Extensions are higher-effort but high-impact locations.

Triage checklist

StepActionTooling
1Collect all launchd directories for every user and the systemAftermath, mac_apt, UAC, or tar with Full Disk Access
2Parse each plist; extract Label, ProgramArguments, RunAtLoad, KeepAlive, intervalsplutil -p, mac_apt AUTOSTART plugin
3Hash and verify signatures of every referenced binaryshasum -a 256, codesign -dv, spctl --assess
4Compare configured jobs with loaded jobslaunchctl print system, launchctl print gui/<uid>
5Dump Background Task Managementsudo sfltool dumpbtm
6Review cron, periodic, profiles, shell rc filescrontab -l, profiles list
7Build a timeline around plist creationstat, 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/LaunchDaemons is a red flag.
  • Interpreter indirection: when ProgramArguments starts with /bin/bash, /usr/bin/python3 or /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 resetbtm and tccutil-style resets alter state. Document first, contain second.
  • Version drift: BTM file versions, sfltool output 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.

Related guides