LaunchAgents and LaunchDaemons: macOS Persistence
launchd property lists in LaunchAgents and LaunchDaemons folders define what macOS starts at boot, at login or on a schedule, and are the top persistence spot.
- Location
- ~/Library/LaunchAgents, /Library/LaunchAgents, /Library/LaunchDaemons
- Proves
- What code is configured to start automatically, as which user, on which trigger, and since when
- Timestamps
- No internal timestamps; APFS file times (nanoseconds, UTC)
- Access
- User agents: owning user; /Library: root; FDA recommended for collector
- Retention
- Until the plist is deleted; overrides and BTM records can outlive it
- Collection
- Aftermath, mac_apt, UAC, Velociraptor
Tools
Compare all tools- mac_aptCLI · open source
- PlasoCLI · open source
- KnockKnockGUI · open source
- plutilCLI · built into macOS
What it is
launchd is PID 1 on macOS. It starts system daemons at boot and per-user agents at login from job definitions stored as property lists. A job plist names a Label, the program to run and the triggers (at load, keep alive, interval, calendar, file path changes). Legitimate software and most macOS malware use exactly the same mechanism, which is why these folders are the first stop in any persistence review.
Daemons run in the system context (root unless UserName says otherwise) without a logged-in user. Agents run inside a user's session.
Where it lives
| Path | Type | Written by | macOS / protection |
|---|---|---|---|
~/Library/LaunchAgents | Per-user agent | The user, no admin needed | All versions |
/Library/LaunchAgents | Agent for every user | Administrator (root) | All versions |
/Library/LaunchDaemons | System daemon | Administrator (root) | All versions |
/System/Library/LaunchAgents, /System/Library/LaunchDaemons | Apple jobs | Apple | Sealed system volume from 11 Big Sur |
/Library/Apple/System/Library/LaunchAgents, .../LaunchDaemons | Apple jobs on the Data volume | Apple | SIP-protected |
<App>.app/Contents/Library/LaunchAgents, .../LaunchDaemons | App-bundled jobs registered with SMAppService | Developer | 13 Ventura+ |
/private/var/db/com.apple.xpc.launchd/disabled.plist, disabled.<UID>.plist | launchd enable/disable overrides | launchctl enable/disable | root |
/private/var/db/com.apple.xpc.launchd/loginitems.<UID>.plist | Service Management login items | SMLoginItemSetEnabled | root |
/Library/StartupItems | Legacy startup items | Administrator | Deprecated mechanism |
Also check /var/root/Library/LaunchAgents and every home under /Users.
What it proves
- A program was configured to start at boot, login, on an interval, on a calendar schedule, on file changes or on volume mount.
- The account the job runs as (daemon without
UserNamemeans root). - Writing to
/Library/LaunchDaemonsrequired root, so a malicious daemon implies privilege escalation. - With the running state (
launchctl print), whether the job is loaded, its PID, run count and last exit status.
A plist alone does not prove the job ever ran: it may be disabled by an override, fail to load, or point at a missing binary. Corroborate with logs and process evidence.
Key fields
From launchd.plist(5):
| Key | Meaning |
|---|---|
Label | Unique job name (required) |
Program / ProgramArguments | Executable and argument vector |
BundleProgram | App-relative executable, SMAppService plists only |
RunAtLoad | Start when loaded (boot or login) |
KeepAlive | Restart on exit, or conditional dictionary |
StartInterval / StartCalendarInterval | Periodic or scheduled start |
WatchPaths / QueueDirectories / StartOnMount | File-system triggers |
UserName / GroupName | Execution identity |
EnvironmentVariables | Environment (watch for DYLD_INSERT_LIBRARIES) |
StandardOutPath / StandardErrorPath | Output files, often useful evidence |
LimitLoadToSessionType | Session types for agents (e.g. Aqua) |
AssociatedBundleIdentifiers | App shown for this job in Login Items settings |
Disabled | Default state; launchctl enable/disable state is kept externally |
Timestamps
Plists carry no creation date of their own. Use the file system: APFS stores birth, modification, change and access times with nanosecond precision in UTC.
stat -f '%SB | %Sm | %N' -t '%Y-%m-%d %H:%M:%S' ~/Library/LaunchAgents/*.plist
Birth time is usually the best "installed at" anchor but can be altered with touch or by copying tools. Check the referenced binary's times, FSEvents for the folder, and the Background Task Management record.
Retention
A job persists until its plist is removed. Removal does not erase everything: disabled*.plist overrides, Background Task Management records, unified log entries, FSEvents, local APFS snapshots and Time Machine backups can keep traces.
Collection
# Dead-box or live (root, Full Disk Access)
sudo ditto /Library/LaunchAgents ./case/la_system
sudo ditto /Library/LaunchDaemons ./case/ld_system
sudo ditto /private/var/db/com.apple.xpc.launchd ./case/launchd_db
for u in /Users/*; do [ -d "$u/Library/LaunchAgents" ] && \
sudo ditto "$u/Library/LaunchAgents" "./case/la_$(basename "$u")"; done
# Live state (read-only)
sudo launchctl print system > ./case/launchctl_system.txt
launchctl print gui/501 > ./case/launchctl_gui501.txt
sudo launchctl print-disabled system > ./case/disabled_system.txt
- UAC
files/system/startup_items.yamlcollects the standard agent and daemon folders (per-user,/Library,/System/Libraryand/Library/Apple/System/Library, but not/var/rootor jobs bundled inside apps),/Library/StartupItemsandloginitems.*.plist;live_response/system/launchctl.yamlrunslaunchctl list. - Aftermath collects launch agents and daemons among its persistence items.
- Never run
launchctl bootout,unloadordisablebefore documenting.
Parsing
- plutil:
plutil -p <file>prints XML or binary plists;plutil -lintflags malformed ones. - mac_apt
AUTOSTARTplugin:python mac_apt.py -o out E01 mac.E01 AUTOSTART. - Plaso
launchd_plistplugin:log2timeline.py --parsers 'plist/launchd_plist' --storage-file ld.plaso ./case. - KnockKnock (objective-see/KnockKnock) enumerates persistent items on a live Mac with signing status.
Quick triage of every job's program:
for f in /Library/Launch*/*.plist ~/Library/LaunchAgents/*.plist; do
printf '%s\t' "$f"; plutil -extract ProgramArguments json -o - "$f" 2>/dev/null \
|| plutil -extract Program raw -o - "$f"; echo
done
Investigator tips
- A
com.apple.*label outside/Systemand/Library/Appleis a red flag: Apple jobs do not live in/Library/LaunchDaemons. - When
ProgramArgumentsstarts with/bin/sh,/bin/bash,/usr/bin/python3or/usr/bin/osascript, the interpreter is Apple-signed; the script argument is the payload. - Hash and check each target:
codesign -dv --verbose=4,spctl --assess -vv,xattr -lfor quarantine. - Targets in hidden folders,
/tmp,/Users/Sharedor vendor-looking names under~/Library/Application Supportdeserve priority. - A job that
launchctl printshows as loaded but has no plist on disk means the file was removed after loading. - Baseline against the organisation's standard build: updaters, VPN, MDM and backup agents are normal noise.
- Also review cron and periodic and see the launchd persistence guide.