5 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

Today, we’ll continue to go dive into macOS shellcoding for x86_64: how to write shellcode in Assembly and execute it from a C program - just like we do on Linux. But you’ll quickly see: macOS plays by different rules. I’ll show you what works, what doesn’t, and why.

practical example 1

Let’s make the smallest possible “shellcode” - just write Meow\n to stdout, then exit.

First of all, we need to push the string Meow\n onto the stack:

mov rax, 0x0a776f654d    ; "\nwoeM" in little-endian: 0x4d 65 6f 77 0a
push rax                 ; put the string (plus null bytes) onto the stack

Here we encode Meow\n as a single 64-bit value and push it onto the stack. After this instruction, rsp points to our string in memory.

Then we prepare arguments for the write syscall:

mov rdi, 1               ; file descriptor 1 (stdout)
mov rsi, rsp             ; pointer to our string ("Meow\n")
mov rdx, 5               ; string length (5 bytes)

rdi is the first argument (fd = 1, meaning stdout), rsi is the second argument (pointer to buffer) and rdx is the third argument (number of bytes to write).

Then call the write syscall:

mov rax, 0x2000004       ; syscall number for write (macOS x86_64)
syscall

As I wrote in the previous posts, write is syscall 4, so the full value is 0x2000004.

Finally, exit the process cleanly:

mov rax, 0x2000001       ; syscall number for exit
xor rdi, rdi             ; exit code 0
syscall

The logic is pretty simple: exit is syscall 1 (0x2000001). Then, we zero out rdi to specify exit code 0 (success).

So the full source code is looks like this meow.asm:

global start

section .text
start:
  mov rax, 0x0a776f654d     ; "\nwoeM" in little-endian
  push rax                  ; string now on stack

  mov rdi, 1                ; fd = 1 (stdout)
  mov rsi, rsp              ; pointer to "Meow\n"
  mov rdx, 5                ; length = 5 bytes
  mov rax, 0x2000004        ; syscall: write
  syscall

  mov rax, 0x2000001        ; syscall: exit
  xor rdi, rdi              ; exit code 0
  syscall

demo

Let’s check this code first, assembly and link:

nasm -f macho64 meow.asm -o meow.o
ld -arch x86_64 -macos_version_min 14.0 -e start -static -o meow meow.o

malware

Then run it (in my case Mac OS X Sonoma VM):

./meow

malware

As you can see, everything is worked as expected, perfectly! =^..^=

practical example 2

My favorite part: get the shellcode bytes!

To run the shellcode from C, you need the opcodes. Extract them with otool:

malware

or via objdump:

malware

Now convert these bytes into a shellcode string:

unsigned char code[] =
"\x48\xb8\x4d\x65\x6f\x77\x0a\x00\x00\x00"
"\x50\xbf\x01\x00\x00\x00"
"\x48\x89\xe6\xba\x05\x00\x00\x00"
"\xb8\x04\x00\x00\x02\x0f\x05"
"\xb8\x01\x00\x00\x02\x48\x31\xff\x0f\x05";

So, let’s go to create code for run it. On linux, you can do it like this:

/*
run.c - a small skeleton program to run shellcode
*/
// bytecode here
char code[] = "my shellcode here";

int main(int argc, char **argv) {
  int (*func)();             // function pointer
  func = (int (*)()) code;   // func points to our shellcode
  (int)(*func)();            // execute a function code[]
  // if our program returned 0 instead of 1, 
  // so our shellcode worked
  return 1;
}

On Linux, you’d use execstack -s or compile with -z execstack. But on macOS, there’s NO execstack. The stack is always non-executable, and there’s no easy way to change that.

Solution? On macOS use mmap to allocate an executable memory page, copy your shellcode there, and jump to it!

As a safety and compatibility measure, we check the memory page size (usually 4096 bytes):

size_t pagesize = sysconf(_SC_PAGESIZE);

We’ll allocate memory aligned to a full page for shellcode execution. The stack is not executable on modern macOS. So we use mmap to create a new memory region that is:

void *exec = mmap(0, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC,
                  MAP_PRIVATE | MAP_ANON, -1, 0);

This memory region is readable (PROT_READ), writable (PROT_WRITE) and executable (PROT_EXEC). MAP_ANON and MAP_PRIVATE make it a fresh, anonymous private page.

Then, copy our shellcode bytes into the new RWX page:

memcpy(exec, code, sizeof(code));

And execute the shellcode:

((void(*)())exec)();

This jumps into your shellcode, which will write Meow\n to stdout and exit the process.

So, the full source code is looks like this:

/*
 * hack.c
 * running shellcode on macOS
 * author @cocomelonc
 * https://cocomelonc.github.io/macos/2025/07/08/malware-mac-5.html
 */
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

unsigned char code[] =
"\x48\xb8\x4d\x65\x6f\x77\x0a\x00\x00\x00"
"\x50\xbf\x01\x00\x00\x00"
"\x48\x89\xe6\xba\x05\x00\x00\x00"
"\xb8\x04\x00\x00\x02\x0f\x05"
"\xb8\x01\x00\x00\x02\x48\x31\xff\x0f\x05";

int main() {
  // get the system memory page size
  size_t pagesize = sysconf(_SC_PAGESIZE);

  // allocate an executable memory page (page-aligned)
  void *exec = mmap(0, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC,
            MAP_PRIVATE | MAP_ANON, -1, 0);

  if (exec == MAP_FAILED) {
    perror("mmap");
    return 1;
  }

  // copy the shellcode into the allocated memory
  memcpy(exec, code, sizeof(code));

  // execute the shellcode
  ((void(*)())exec)();

  // free the allocated memory (optional, since program will exit)
  // munmap(exec, pagesize);

  return 0;
}

demo

Let’s go to see everything in action. Compile:

clang -o hack hack.c

malware

And run it:

malware

Why this matters? Modern macOS disables stack execution, so you must use mmap with PROT_EXEC. If you forget this, you’ll get bus error or segmentation fault when trying to run code from the stack or heap.

Honestly, this pattern is universal for in-memory shellcode execution in C on macOS and other modern *nix OSes.

This example is for Intel (x86_64):

malware

On ARM64/M1, syscalls, calling conventions, and opcodes are different. We need to write separate ARM64 ASM, assemble with nasm -f macho64, and test!

But I decided it would be better to make a separate article for ARM/M1 assembly: even experienced readers often don’t understand both at once. If you mix x86_64 and ARM64, you’ll get a mess.

conclusion

Running raw shellcode on macOS is absolutely possible - but you can’t just drop it on the stack like in the old days. You need to allocate RX memory yourself.

For malware devs, red teamers, or CTF nerds: this is essential for any post-exploitation payload.

I hope this post is useful for malware researchers, macOS/Apple security researchers, ASM/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
Apple Open Source: Releases
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