Malicious LaunchAgents on macOS: Detection, Investigation, and Fleet Removal for Security Teams

Retool's new app builder is where AI-generated code ships safely
Building apps with AI is easy. Getting them to production safely is another story.
macOS LaunchAgents are property list (plist) files that tell macOS to run a program automatically at login or on a schedule. They are the most common persistence mechanism for macOS malware precisely because they are easy to install (no root required for user-level agents), survive reboots, and are rarely audited. The TanStack supply chain payload used a LaunchAgent named com.user.gh-token-monitor to continue exfiltrating credentials even after the malicious npm package was removed from the developer's machine.
LaunchAgent Basics for Security Teams
macOS has four relevant persistence directories:
- ~/Library/LaunchAgents/: User-level agents. Run as the logged-in user at login. No administrator privileges required to install. This is where most malware persists.
- /Library/LaunchAgents/: System-wide agents that run as the logged-in user. Requires admin/root to write.
- /Library/LaunchDaemons/: System-wide daemons that run as root. Requires admin/root to write. Higher privilege but requires more access to install.
- /System/Library/LaunchDaemons/: Apple system services. Should never contain third-party entries.
A LaunchAgent plist file has a minimum of two keys:
- Label: A reverse-domain identifier (e.g., com.user.gh-token-monitor)
- ProgramArguments: The command to run
Optional but common:
- RunAtLoad: true, run immediately when loaded (at login)
- StartInterval: N, run every N seconds
- KeepAlive: true, restart if the process exits
Step 1: Audit LaunchAgents Across Your Fleet with osquery
osquery provides a standardized way to query system state across your macOS fleet. If you have osquery deployed (standalone or via tools like Fleet, Kolide, or Jamf Pro with osquery integration), run this query against all your macOS endpoints:
SELECT
path,
name,
label,
program,
program_arguments,
run_at_load,
keep_alive,
start_interval
FROM launchd
WHERE
path LIKE '/Users/%/Library/LaunchAgents/%'
OR path LIKE '/Library/LaunchAgents/%'
ORDER BY path;
This returns every LaunchAgent across all user profiles on the machine. For a fleet of 50+ machines, run this via osquery's distributed query feature and export the results to a spreadsheet.
For machines without osquery, use a shell script deployed via Jamf or Ansible:
find ~/Library/LaunchAgents /Library/LaunchAgents /Library/LaunchDaemons \
-name '*.plist' 2>/dev/null | while read f; do
echo "=== $f ==="
/usr/libexec/PlistBuddy -c 'Print :Label' "$f" 2>/dev/null
/usr/libexec/PlistBuddy -c 'Print :ProgramArguments' "$f" 2>/dev/null
done
Briefings like this, every morning before 9am.
Threat intel, active CVEs, and campaign alerts, distilled for practitioners. 50,000+ subscribers. No noise.
Step 2: Distinguish Malicious from Legitimate LaunchAgents
A typical developer machine has between 20 and 60 LaunchAgents. Most are legitimate. Distinguish them by checking these attributes:
Publisher verification: Legitimate LaunchAgents from major software vendors (Adobe, Dropbox, Zoom, Microsoft) have consistent naming patterns (e.g., com.adobe., com.microsoft.). An entry with a generic label like com.user.* or com.apple.* in the user LaunchAgents directory (~/Library/LaunchAgents/) is suspicious, Apple's own services are in /System/Library/, not the user directory.
Executable path: Check the ProgramArguments key. Legitimate agents point to executables in /Applications/, /usr/local/bin/, or other standard locations. An agent pointing to a binary in ~/Library/Application Support/ with a non-descriptive name, or to a shell script in a temp directory, is a red flag.
Code signing: Check whether the executable is signed by Apple or a verified developer:
codesign -dv /path/to/executable 2>&1
Look for Authority= lines. An unsigned executable or one signed by a developer certificate with an unfamiliar name warrants investigation.
Installation time: The plist file's modification time indicates when it was installed. An entry that appeared recently (within the last 30 days) and was not associated with a software installation you remember is suspicious.
Step 3: Investigate a Suspicious LaunchAgent
When you find a suspicious entry, do not remove it yet, investigate first to understand the scope.
Read the full plist:
cat ~/Library/LaunchAgents/com.user.gh-token-monitor.plist
Check when it was installed and by what process:
mdls ~/Library/LaunchAgents/com.user.gh-token-monitor.plist
# Look for kMDItemDateAdded
Correlate the installation time with your shell history, what did you run around that time?
grep -h '' ~/.zsh_history | grep -B5 -A5 'npm install'
Examine the executable it runs:
# Get the executable path from the plist
EXEC=$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:0' \
~/Library/LaunchAgents/com.user.gh-token-monitor.plist)
# Check file type and hash
file "$EXEC"
sha256sum "$EXEC"
Submit the hash to VirusTotal or compare against known IOCs from the relevant security advisory.
Check network activity of the running process: If the LaunchAgent is currently running:
lsof -i -n -P | grep $(pgrep -f gh-token-monitor)
This shows current network connections, revealing the C2 server the malware is communicating with.
Step 4: Remove at Scale via MDM
Once you have confirmed a malicious LaunchAgent label, remove it across your fleet before the attackers know you are aware.
For immediate removal via Jamf: Create a Jamf policy with a shell script payload:
#!/bin/bash
AGENT="$HOME/Library/LaunchAgents/com.user.gh-token-monitor.plist"
if [ -f "$AGENT" ]; then
launchctl unload "$AGENT" 2>/dev/null
rm -f "$AGENT"
echo "Removed malicious LaunchAgent"
logger -t security "Malicious LaunchAgent removed: $AGENT"
else
echo "LaunchAgent not found"
fi
Set the policy scope to All Computers and trigger it as a self-service policy with immediate execution, or trigger via API for instant fleet-wide deployment.
For manual removal on a single machine:
launchctl unload ~/Library/LaunchAgents/com.user.gh-token-monitor.plist
rm ~/Library/LaunchAgents/com.user.gh-token-monitor.plist
Also check for the associated executable and remove it:
rm ~/Library/Application\ Support/com.user.gh-token-monitor # or whatever path the plist referenced
Ongoing Detection: osquery Scheduled Query
Add a scheduled osquery query to your fleet that runs every 6 hours and alerts on any new user LaunchAgent not in your approved baseline:
{
"schedule": {
"new_launch_agents": {
"query": "SELECT path, label, program_arguments FROM launchd WHERE path LIKE '/Users/%/Library/LaunchAgents/%';",
"interval": 21600,
"description": "Detect new user LaunchAgents for persistence monitoring"
}
}
}
Pair this with a differential check, alert only on entries that did not exist in the previous run. Integrate the output with your SIEM or send to Slack/Teams via osquery's logger plugin.
For a faster check without osquery, deploy a Launch Constraint configuration via MDM that restricts which executables can be loaded as LaunchAgents, this is a macOS 13+ feature that blocks unsigned or unknown binaries from persisting via the LaunchAgent mechanism.
The bottom line
A malicious LaunchAgent installed in ~/Library/LaunchAgents/ requires zero administrator privileges, survives reboots, and continues operating after the initial malware payload is removed. The two controls that stop this class of persistence are: osquery scheduled queries that detect new LaunchAgents within hours of installation, and MDM-enforced removal scripts that can reach every machine in your fleet within minutes of confirmation.
Frequently asked questions
What is the difference between a LaunchAgent and a LaunchDaemon?
LaunchAgents run as the logged-in user and are loaded at login. LaunchDaemons run as root (or another system user) and are loaded at boot, before any user logs in. Malware more commonly uses LaunchAgents for user-level persistence because they do not require administrator access to install.
How do I list all currently loaded LaunchAgents on my machine?
Run: launchctl list | grep -v '^-' to see all loaded services. The output shows the PID (or - if not running), exit status, and label. To filter to only user-level agents you should recognize: launchctl list | grep 'com.user'
Can a LaunchAgent survive a macOS reinstall?
A standard reinstall that preserves user data (migration from Time Machine or from an old machine) will carry over ~/Library/LaunchAgents/ and the persistence will survive. A clean install with a new user account will not. If you are cleaning an infected machine, either perform a clean install or manually remove all LaunchAgent plist files and their associated executables before migrating user data.
What should I do if I find a LaunchAgent I do not recognize but it is not flagged by VirusTotal?
Zero detections on VirusTotal does not mean clean. Read the plist to find the executable it runs, then examine what that executable does: run strings on it to identify embedded URLs or filenames, check its code signing, review its network connections if it is running, and compare the installation time against your recent activity. When in doubt, remove it, legitimate software reinstalls cleanly.
Does Apple Silicon (M1/M2/M3) change how LaunchAgent persistence works?
The LaunchAgent mechanism is the same on Apple Silicon. The relevant difference is that Apple Silicon enforces stricter code signing requirements for executables, binaries must be signed or they are blocked by Gatekeeper. However, attackers can sign with an ad-hoc signature, a free Apple Developer certificate, or steal a valid certificate, so code signing alone is not a reliable indicator of legitimacy.
What is the fastest way to check whether a specific LaunchAgent label is running on a remote Mac without physical access?
Use Jamf Remote or your MDM's remote script execution to run: launchctl list | grep 'LABEL-YOU-ARE-LOOKING-FOR' on the target machine. If you have osquery deployed, the query SELECT label, pid, program_arguments FROM launchd WHERE label LIKE '%gh-token-monitor%' returns the result across the entire fleet in a single distributed query. For machines where neither is available, deploy a lightweight shell script via Jamf policy that writes launchctl list output to a file in a shared network location, then reads and filters that file centrally. The osquery route is the most scalable because it queries all endpoints simultaneously and returns structured results rather than raw text.
Sources & references
Free resources
Critical CVE Reference Card 2025–2026
25 actively exploited vulnerabilities with CVSS scores, exploit status, and patch availability. Print it, pin it, share it with your SOC team.
Ransomware Incident Response Playbook
Step-by-step 24-hour IR checklist covering detection, containment, eradication, and recovery. Built for SOC teams, IR leads, and CISOs.
Get threat intel before your inbox does.
50,000+ security professionals read Decryption Digest for early warnings on zero-days, ransomware, and nation-state campaigns. Free, daily, no spam.
Unsubscribe anytime. We never sell your data.

Founder & Cybersecurity Evangelist, Decryption Digest
Cybersecurity professional with expertise in threat intelligence, vulnerability research, and enterprise security. Covers zero-days, ransomware, and nation-state operations for 50,000+ security professionals every morning.
Win a $2,495 Black Hat pass.
Full-access to Black Hat USA 2026 in Las Vegas. Subscribe free to enter.
