Skip to content

PersistenceExecution

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

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

PathTypeWritten bymacOS / protection
~/Library/LaunchAgentsPer-user agentThe user, no admin neededAll versions
/Library/LaunchAgentsAgent for every userAdministrator (root)All versions
/Library/LaunchDaemonsSystem daemonAdministrator (root)All versions
/System/Library/LaunchAgents, /System/Library/LaunchDaemonsApple jobsAppleSealed system volume from 11 Big Sur
/Library/Apple/System/Library/LaunchAgents, .../LaunchDaemonsApple jobs on the Data volumeAppleSIP-protected
<App>.app/Contents/Library/LaunchAgents, .../LaunchDaemonsApp-bundled jobs registered with SMAppServiceDeveloper13 Ventura+
/private/var/db/com.apple.xpc.launchd/disabled.plist, disabled.<UID>.plistlaunchd enable/disable overrideslaunchctl enable/disableroot
/private/var/db/com.apple.xpc.launchd/loginitems.<UID>.plistService Management login itemsSMLoginItemSetEnabledroot
/Library/StartupItemsLegacy startup itemsAdministratorDeprecated 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 UserName means root).
  • Writing to /Library/LaunchDaemons required 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):

KeyMeaning
LabelUnique job name (required)
Program / ProgramArgumentsExecutable and argument vector
BundleProgramApp-relative executable, SMAppService plists only
RunAtLoadStart when loaded (boot or login)
KeepAliveRestart on exit, or conditional dictionary
StartInterval / StartCalendarIntervalPeriodic or scheduled start
WatchPaths / QueueDirectories / StartOnMountFile-system triggers
UserName / GroupNameExecution identity
EnvironmentVariablesEnvironment (watch for DYLD_INSERT_LIBRARIES)
StandardOutPath / StandardErrorPathOutput files, often useful evidence
LimitLoadToSessionTypeSession types for agents (e.g. Aqua)
AssociatedBundleIdentifiersApp shown for this job in Login Items settings
DisabledDefault 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.yaml collects the standard agent and daemon folders (per-user, /Library, /System/Library and /Library/Apple/System/Library, but not /var/root or jobs bundled inside apps), /Library/StartupItems and loginitems.*.plist; live_response/system/launchctl.yaml runs launchctl list.
  • Aftermath collects launch agents and daemons among its persistence items.
  • Never run launchctl bootout, unload or disable before documenting.

Parsing

  • plutil: plutil -p <file> prints XML or binary plists; plutil -lint flags malformed ones.
  • mac_apt AUTOSTART plugin: python mac_apt.py -o out E01 mac.E01 AUTOSTART.
  • Plaso launchd_plist plugin: 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 /System and /Library/Apple is a red flag: Apple jobs do not live in /Library/LaunchDaemons.
  • When ProgramArguments starts with /bin/sh, /bin/bash, /usr/bin/python3 or /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 -l for quarantine.
  • Targets in hidden folders, /tmp, /Users/Shared or vendor-looking names under ~/Library/Application Support deserve priority.
  • A job that launchctl print shows 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.

See also