#!/usr/bin/env python3 """ isp-sees: watch, live, what your internet provider can see. Mirrors an iPhone's traffic over a USB cable (or captures this Mac's own traffic) and shows every site name that leaves the device in the clear: DNS lookups, TLS SNI and QUIC SNI. It also notices when all you send is an encrypted WireGuard tunnel. Everything runs on your Mac. Nothing is sent anywhere. sudo python3 isp_sees.py iPhone connected by cable sudo python3 isp_sees.py --mac this Mac's own traffic python3 isp_sees.py --pcap file.pcap replay a saved capture MIT License """ import argparse import atexit import collections import hashlib import hmac import ipaddress import json import os import queue import re import shutil import socket import struct import subprocess import sys import threading import time import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # When re-launched through sudo, root can't see packages the user installed # with "pip3 install --user". Add the original user's site-packages back. def _add_user_site(): user = os.environ.get("SUDO_USER") if not user: return home = os.path.expanduser("~" + user) ver = "%d.%d" % sys.version_info[:2] for path in ( os.path.join(home, "Library", "Python", ver, "lib", "python", "site-packages"), os.path.join(home, ".local", "lib", "python" + ver, "site-packages"), ): if os.path.isdir(path) and path not in sys.path: sys.path.append(path) _add_user_site() try: # only needed to read site names inside QUIC (HTTP/3) packets from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers.aead import AESGCM HAVE_CRYPTO = True except Exception: HAVE_CRYPTO = False # -------------------------------------------------------------------------- # What kind of site is this? # -------------------------------------------------------------------------- TRACKERS = ( "doubleclick.net", "googlesyndication.com", "googleadservices.com", "google-analytics.com", "googletagmanager.com", "googletagservices.com", "app-measurement.com", "adservice.google.com", "crashlytics.com", "firebaselogging-pa.googleapis.com", "connect.facebook.net", "an.facebook.com", "facebook.net", "analytics.tiktok.com", "ads.tiktok.com", "appsflyer.com", "appsflyersdk.com", "adjust.com", "adjust.world", "branch.io", "app.link", "amplitude.com", "mixpanel.com", "segment.io", "segment.com", "braze.com", "onesignal.com", "scorecardresearch.com", "criteo.com", "criteo.net", "taboola.com", "outbrain.com", "amazon-adsystem.com", "adsrvr.org", "rubiconproject.com", "pubmatic.com", "openx.net", "casalemedia.com", "adnxs.com", "moatads.com", "quantserve.com", "hotjar.com", "clarity.ms", "bat.bing.com", "mc.yandex.ru", "sentry.io", "nr-data.net", "newrelic.com", "unityads.unity3d.com", "applovin.com", "applvn.com", "ironsrc.mobi", "vungle.com", "chartboost.com", "inmobi.com", "startappservice.com", "bugsnag.com", "demdex.net", "omtrdc.net", "everesttech.net", "krxd.net", "bluekai.com", "adsafeprotected.com", "doubleverify.com", "chartbeat.com", "chartbeat.net", "parsely.com", "tealiumiq.com", "optimizely.com", "mxpnl.com", "kochava.com", "singular.net", "datadoghq.com", "browser-intake-datadoghq.com", "smartadserver.com", "teads.tv", "yieldmo.com", "3lift.com", "sharethrough.com", "media.net", "ads-twitter.com", "analytics.twitter.com", "sc-analytics.appspot.com", "tr.snapchat.com", "ct.pinterest.com", "px.ads.linkedin.com", "snap.licdn.com", ) TRACKER_FIRST_LABELS = { "analytics", "metrics", "telemetry", "tracking", "tracker", "pixel", "ads", "adservice", "adserver", "stats", "beacon", "collector", } SOCIAL = ( "facebook.com", "fbcdn.net", "fbsbx.com", "instagram.com", "cdninstagram.com", "whatsapp.net", "whatsapp.com", "tiktok.com", "tiktokcdn.com", "tiktokv.com", "tiktokcdn-us.com", "byteoversea.com", "ibytedtos.com", "twitter.com", "x.com", "twimg.com", "snapchat.com", "sc-cdn.net", "snapkit.com", "reddit.com", "redd.it", "redditmedia.com", "redditstatic.com", "linkedin.com", "licdn.com", "pinterest.com", "pinimg.com", "telegram.org", "t.me", "discord.com", "discord.gg", "discordapp.com", "threads.net", "threads.com", "youtube.com", "ytimg.com", "googlevideo.com", "youtu.be", "vk.com", "userapi.com", "bsky.app", "tumblr.com", "twitch.tv", "onlyfans.com", "tinder.com", "gotinder.com", "bumble.com", "hinge.co", "badoo.com", ) APPLE = ( "apple.com", "icloud.com", "icloud-content.com", "mzstatic.com", "apple-dns.net", "aaplimg.com", "cdn-apple.com", "apple-cloudkit.com", "apple.news", "itunes.com", "apple-mapkit.com", "me.com", "push-apple.com.akadns.net", ) GOOGLE = ( "google.com", "gstatic.com", "googleapis.com", "googleusercontent.com", "gmail.com", "ggpht.com", "google.com.ua", "gvt1.com", "gvt2.com", ) TWO_LEVEL_SUFFIXES = { "co.uk", "org.uk", "ac.uk", "gov.uk", "com.ua", "net.ua", "org.ua", "in.ua", "kiev.ua", "kyiv.ua", "gov.ua", "com.au", "net.au", "org.au", "co.jp", "ne.jp", "or.jp", "com.br", "com.tr", "co.in", "co.kr", "com.cn", "com.mx", "co.za", "com.pl", "com.sg", "com.hk", "co.nz", "co.il", "com.ar", "akadns.net", "cloudfront.net", "amazonaws.com", "azureedge.net", "edgekey.net", } def _matches(host, suffixes): for s in suffixes: if host == s or host.endswith("." + s): return True return False def classify(host): if _matches(host, TRACKERS) or host.split(".", 1)[0] in TRACKER_FIRST_LABELS: return "tracker" if _matches(host, SOCIAL): return "social" if _matches(host, APPLE): return "apple" if _matches(host, GOOGLE): return "google" return "other" def base_domain(host): parts = host.split(".") if len(parts) <= 2: return host if ".".join(parts[-2:]) in TWO_LEVEL_SUFFIXES and len(parts) >= 3: return ".".join(parts[-3:]) return ".".join(parts[-2:]) def clean_host(name): name = name.strip().strip(".").lower() if not name or "." not in name or len(name) > 253: return None if name.endswith((".arpa", ".local", ".localhost", ".lan", ".home", ".internal")): return None if name.startswith("_"): return None if not re.match(r"^[a-z0-9._-]+$", name): return None return name # -------------------------------------------------------------------------- # Event hub: keeps history and streams events to the dashboard # -------------------------------------------------------------------------- class Hub: def __init__(self): self.lock = threading.Lock() self.clients = [] self.history = collections.deque(maxlen=4000) self.recent = {} self.status = {"kind": "status", "source": "Starting", "link": "", "started": time.time(), "quic": HAVE_CRYPTO} self.packets = 0 self.bytes = 0 self.seen_hosts = set() self.echo = True def subscribe(self): q = queue.Queue(maxsize=20000) with self.lock: snapshot = {"status": self.status, "events": list(self.history), "packets": self.packets, "bytes": self.bytes} self.clients.append(q) return q, snapshot def unsubscribe(self, q): with self.lock: if q in self.clients: self.clients.remove(q) def _broadcast(self, ev, keep=True): with self.lock: if keep: self.history.append(ev) dead = [] for q in self.clients: try: q.put_nowait(ev) except queue.Full: dead.append(q) for q in dead: self.clients.remove(q) def set_status(self, **kw): self.status.update(kw) self._broadcast(dict(self.status), keep=False) def clear(self): with self.lock: self.history.clear() self.recent.clear() self.seen_hosts.clear() def count_packet(self, size): self.packets += 1 self.bytes += size def domain(self, host, via, app=None, ech=False): now = time.time() key = (host, via) last = self.recent.get(key) if last is not None and now - last < 2.0: return self.recent[key] = now if len(self.recent) > 5000: cutoff = now - 10 self.recent = {k: v for k, v in self.recent.items() if v > cutoff} ev = {"kind": "domain", "t": now, "host": host, "base": base_domain(host), "cat": classify(host), "via": via} if app: ev["app"] = app if ech: ev["ech"] = True if self.echo and host not in self.seen_hosts: print(" %s %-4s %s" % (time.strftime("%H:%M:%S"), via, host), flush=True) self.seen_hosts.add(host) self._broadcast(ev) def tunnel(self, peer, port, packets, size): ev = {"kind": "tunnel", "t": time.time(), "peer": peer, "port": port, "packets": packets, "bytes": size} self._broadcast(ev) def stats_loop(self): while True: time.sleep(2) self._broadcast({"kind": "stats", "packets": self.packets, "bytes": self.bytes}, keep=False) # -------------------------------------------------------------------------- # Reading captures (pcap and pcapng, from tcpdump or a file) # -------------------------------------------------------------------------- def read_exact(f, n): buf = b"" while len(buf) < n: chunk = f.read(n - len(buf)) if not chunk: return None buf += chunk return buf def read_capture(f): """Yield (linktype, timestamp, frame) from a pcap or pcapng stream.""" magic = read_exact(f, 4) if magic is None: return if magic == b"\x0a\x0d\x0d\x0a": yield from _read_pcapng(f) return if magic in (b"\xd4\xc3\xb2\xa1", b"\x4d\x3c\xb2\xa1"): e = "<" elif magic in (b"\xa1\xb2\xc3\xd4", b"\xa1\xb2\x3c\x4d"): e = ">" else: raise ValueError("Not a pcap/pcapng capture") nano = magic in (b"\x4d\x3c\xb2\xa1", b"\xa1\xb2\x3c\x4d") hdr = read_exact(f, 20) if hdr is None: return linktype = struct.unpack(e + "HHiIII", hdr)[5] & 0x0FFFFFFF div = 1e9 if nano else 1e6 while True: rh = read_exact(f, 16) if rh is None: return sec, frac, incl, _orig = struct.unpack(e + "IIII", rh) data = read_exact(f, incl) if data is None: return yield linktype, sec + frac / div, data def _read_pcapng(f): rest = read_exact(f, 8) if rest is None: return e = "<" if rest[4:8] == b"\x4d\x3c\x2b\x1a" else ">" total = struct.unpack(e + "I", rest[:4])[0] if read_exact(f, total - 12) is None: return ifaces = [] while True: bh = read_exact(f, 8) if bh is None: return btype, blen = struct.unpack(e + "II", bh) if btype == 0x0A0D0D0A: more = read_exact(f, 4) if more is None: return e = "<" if more == b"\x4d\x3c\x2b\x1a" else ">" blen = struct.unpack(e + "I", bh[4:8])[0] if read_exact(f, blen - 12) is None: return ifaces = [] continue if blen < 12: return body = read_exact(f, blen - 8) if body is None: return body = body[:-4] if btype == 1 and len(body) >= 8: lt = struct.unpack(e + "H", body[:2])[0] res = 1e-6 p = 8 while p + 4 <= len(body): code, olen = struct.unpack(e + "HH", body[p:p + 4]) if code == 0: break if code == 9 and olen >= 1: v = body[p + 4] res = 2.0 ** -(v & 0x7F) if v & 0x80 else 10.0 ** -v p += 4 + ((olen + 3) & ~3) ifaces.append((lt, res)) elif btype == 6 and len(body) >= 20: iid, th, tl, cap, _orig = struct.unpack(e + "IIIII", body[:20]) lt, res = ifaces[iid] if iid < len(ifaces) else (1, 1e-6) yield lt, ((th << 32) | tl) * res, body[20:20 + cap] elif btype == 3 and len(body) >= 4: lt = ifaces[0][0] if ifaces else 1 yield lt, time.time(), body[4:] # -------------------------------------------------------------------------- # Link layer -> IP # -------------------------------------------------------------------------- def _ipv4_checksum_ok(h): if len(h) < 20 or len(h) % 2: return False s = sum(struct.unpack("!%dH" % (len(h) // 2), h)) while s >> 16: s = (s & 0xFFFF) + (s >> 16) return s == 0xFFFF def find_ip(d, limit=256): """Last resort: scan for a valid IPv4/IPv6 header.""" n = len(d) for off in range(0, min(limit, max(0, n - 20))): b = d[off] if 0x45 <= b <= 0x4F: ihl = (b & 0xF) * 4 if off + ihl <= n: tl = struct.unpack(">H", d[off + 2:off + 4])[0] if ihl <= tl <= n - off and _ipv4_checksum_ok(d[off:off + ihl]): return d[off:] elif b >> 4 == 6 and n - off >= 40: pl = struct.unpack(">H", d[off + 4:off + 6])[0] if pl + 40 == n - off and d[off + 6] in (6, 17, 58, 0, 43, 44, 60): return d[off:] return None def from_dlt(dlt, d): if dlt == 1: # Ethernet if len(d) < 14: return None et = struct.unpack(">H", d[12:14])[0] off = 14 while et in (0x8100, 0x88A8) and len(d) >= off + 4: et = struct.unpack(">H", d[off + 2:off + 4])[0] off += 4 return d[off:] if et in (0x0800, 0x86DD) else None if dlt in (0, 108): # BSD null / loopback return d[4:] if dlt in (12, 14, 101, 228, 229): # raw IP return d if dlt == 113: # Linux cooked return d[16:] if dlt == 276: # Linux cooked v2 return d[20:] return find_ip(d) def decode_link(linktype, data): """Return (ip_packet, interface_name, process_name) or None.""" if linktype in (258, 149): # Apple PKTAP: per-packet interface + process if len(data) < 12: return None for e in ("<", ">"): hlen = struct.unpack(e + "I", data[0:4])[0] if 12 <= hlen <= len(data): break else: ip = find_ip(data) return (ip, "", "") if ip else None dlt = struct.unpack(e + "I", data[8:12])[0] ifname = data[12:36].split(b"\0", 1)[0].decode("ascii", "replace") if hlen >= 36 else "" proc = data[56:73].split(b"\0", 1)[0].decode("utf-8", "replace") if hlen >= 73 else "" ip = from_dlt(dlt, data[hlen:]) return (ip, ifname, proc) if ip else None ip = from_dlt(linktype, data) return (ip, "", "") if ip else None def parse_ip(p): """Return (proto, src, dst, l4_bytes) or None.""" if not p: return None v = p[0] >> 4 if v == 4: if len(p) < 20: return None ihl = (p[0] & 0xF) * 4 tl = struct.unpack(">H", p[2:4])[0] if tl < ihl or tl > len(p): tl = len(p) if struct.unpack(">H", p[6:8])[0] & 0x1FFF: return None src = socket.inet_ntop(socket.AF_INET, p[12:16]) dst = socket.inet_ntop(socket.AF_INET, p[16:20]) return p[9], src, dst, p[ihl:tl] if v == 6: if len(p) < 40: return None nh = p[6] pl = struct.unpack(">H", p[4:6])[0] end = 40 + pl if pl and 40 + pl <= len(p) else len(p) src = socket.inet_ntop(socket.AF_INET6, p[8:24]) dst = socket.inet_ntop(socket.AF_INET6, p[24:40]) off = 40 while nh in (0, 43, 44, 51, 60): if off + 8 > len(p): return None if nh == 44: if struct.unpack(">H", p[off + 2:off + 4])[0] >> 3: return None nh, off = p[off], off + 8 elif nh == 51: nh, off = p[off], off + (p[off + 1] + 2) * 4 else: nh, off = p[off], off + (p[off + 1] + 1) * 8 return nh, src, dst, p[off:end] return None # -------------------------------------------------------------------------- # Protocol parsers # -------------------------------------------------------------------------- def _read_dns_name(d, off): labels = [] jumps = 0 end = None while True: if off >= len(d): return None, off ln = d[off] if ln == 0: off += 1 break if ln & 0xC0 == 0xC0: if off + 1 >= len(d): return None, off if end is None: end = off + 2 off = ((ln & 0x3F) << 8) | d[off + 1] jumps += 1 if jumps > 10: return None, off continue off += 1 labels.append(d[off:off + ln]) off += ln name = ".".join(x.decode("ascii", "replace") for x in labels) return name, (end if end is not None else off) def dns_query_names(d): if len(d) < 12: return [] flags, qd = struct.unpack(">HH", d[2:6]) if flags & 0x8000 or (flags >> 11) & 0xF: return [] out = [] off = 12 for _ in range(min(qd, 4)): name, off = _read_dns_name(d, off) if name is None or off + 4 > len(d): break qtype = struct.unpack(">H", d[off:off + 2])[0] off += 4 if qtype != 12: # skip reverse lookups out.append(name) return out def parse_client_hello(hs): """hs starts at a TLS handshake header. Return (sni, ech, complete) or None.""" if len(hs) < 4 or hs[0] != 1: return None ln = int.from_bytes(hs[1:4], "big") body = hs[4:4 + ln] complete = len(body) >= ln sni, ech = None, False try: p = 34 p += 1 + body[p] p += 2 + struct.unpack(">H", body[p:p + 2])[0] p += 1 + body[p] el = struct.unpack(">H", body[p:p + 2])[0] p += 2 end = min(len(body), p + el) while p + 4 <= end: et, elen = struct.unpack(">HH", body[p:p + 4]) p += 4 if p + elen > len(body): break # this extension hasn't fully arrived yet ed = body[p:p + elen] p += elen if et == 0 and len(ed) >= 5: q = 2 while q + 3 <= len(ed): nt = ed[q] nl = struct.unpack(">H", ed[q + 1:q + 3])[0] q += 3 if nt == 0 and q + nl <= len(ed): sni = ed[q:q + nl].decode("ascii", "replace") break q += nl elif et == 0xFE0D: ech = True except (IndexError, struct.error): pass return sni, ech, complete def tls_handshake_from_stream(buf): """Join handshake bytes from the TLS records at the start of a TCP stream.""" out = bytearray() p = 0 while p + 5 <= len(buf) and buf[p] == 0x16: rl = struct.unpack(">H", buf[p + 3:p + 5])[0] out += buf[p + 5:p + 5 + rl] p += 5 + rl return bytes(out) def read_varint(d, p): if p >= len(d): return None, p b = d[p] ln = 1 << (b >> 6) if p + ln > len(d): return None, p v = b & 0x3F for i in range(1, ln): v = (v << 8) | d[p + i] return v, p + ln QUIC_V1, QUIC_V2 = 0x00000001, 0x6B3343CF QUIC_SALT = { QUIC_V1: bytes.fromhex("38762cf7f55934b34d179ae6a4c80cadccbb7f0a"), QUIC_V2: bytes.fromhex("0dede3def700a6db819381be6e269dcbf9bd2ed9"), } QUIC_LABELS = { QUIC_V1: (b"quic key", b"quic iv", b"quic hp"), QUIC_V2: (b"quicv2 key", b"quicv2 iv", b"quicv2 hp"), } QUIC_INITIAL_TYPE = {QUIC_V1: 0, QUIC_V2: 1} def _hkdf_expand_label(secret, label, length): full = b"tls13 " + label info = struct.pack(">H", length) + bytes([len(full)]) + full + b"\x00" out, t, i = b"", b"", 1 while len(out) < length: t = hmac.new(secret, t + info + bytes([i]), hashlib.sha256).digest() out += t i += 1 return out[:length] _quic_keys = {} def _quic_client_keys(version, dcid): k = (version, dcid) if k not in _quic_keys: if len(_quic_keys) > 2000: _quic_keys.clear() initial = hmac.new(QUIC_SALT[version], dcid, hashlib.sha256).digest() client = _hkdf_expand_label(initial, b"client in", 32) lk, li, lh = QUIC_LABELS[version] _quic_keys[k] = (_hkdf_expand_label(client, lk, 16), _hkdf_expand_label(client, li, 12), _hkdf_expand_label(client, lh, 16)) return _quic_keys[k] def quic_client_initial(d): """Decrypt a QUIC client Initial. Return (dcid, [(offset, crypto_bytes)]) or None. Initial packets are only protected with keys anyone can compute from the packet itself, which is why a provider can read the site name in them.""" if not HAVE_CRYPTO or len(d) < 30 or not d[0] & 0x80: return None version = struct.unpack(">I", d[1:5])[0] if version not in QUIC_SALT or (d[0] >> 4) & 3 != QUIC_INITIAL_TYPE[version]: return None p = 5 dl = d[p] p += 1 if dl > 20: return None dcid = bytes(d[p:p + dl]) p += dl if p >= len(d): return None p += 1 + d[p] tl, p = read_varint(d, p) if tl is None: return None p += tl length, p = read_varint(d, p) if length is None or length < 20 or p + length > len(d): return None key, iv, hp = _quic_client_keys(version, dcid) sample = bytes(d[p + 4:p + 20]) enc = Cipher(algorithms.AES(hp), modes.ECB()).encryptor() mask = enc.update(sample) + enc.finalize() first = d[0] ^ (mask[0] & 0x0F) pn_len = (first & 3) + 1 pn_bytes = bytes(d[p + i] ^ mask[1 + i] for i in range(pn_len)) pn = int.from_bytes(pn_bytes, "big") header = bytes([first]) + bytes(d[1:p]) + pn_bytes nonce = bytearray(iv) for i in range(8): nonce[11 - i] ^= (pn >> (8 * i)) & 0xFF try: plain = AESGCM(key).decrypt(bytes(nonce), bytes(d[p + pn_len:p + length]), header) except Exception: return None frames = [] q = 0 while q < len(plain): ft, q = read_varint(plain, q) if ft is None: break if ft in (0x00, 0x01): continue if ft in (0x02, 0x03): vals = [] for _ in range(4): v, q = read_varint(plain, q) vals.append(v) if None in vals: break for _ in range(vals[2] * 2 + (3 if ft == 0x03 else 0)): v, q = read_varint(plain, q) if v is None: break continue if ft == 0x06: off, q = read_varint(plain, q) ln, q = read_varint(plain, q) if off is None or ln is None: break frames.append((off, plain[q:q + ln])) q += ln continue break return dcid, frames def wireguard_kind(d): if len(d) < 32 or d[1:4] != b"\x00\x00\x00": return None t, n = d[0], len(d) if (t, n) in ((1, 148), (2, 92), (3, 64)): return t if t == 4 and (n - 16) % 16 == 0: return t return None def _is_global(ip): try: return ipaddress.ip_address(ip).is_global except ValueError: return False # -------------------------------------------------------------------------- # Analyzer: turns packets into "your provider saw X" events # -------------------------------------------------------------------------- HIDDEN_IFACES = ("utun", "ipsec", "lo", "awdl", "llw", "anpi", "ap1", "bridge-") QUIET_PROCS = {"", "kernel_task", "mDNSResponder"} class Analyzer: def __init__(self, hub, include_tunnels=False): self.hub = hub self.include_tunnels = include_tunnels self.tcp = {} self.quic = {} self.wg = {} self.ifaces = set() self.last_gc = time.time() def feed(self, linktype, ts, frame): self.hub.count_packet(len(frame)) r = decode_link(linktype, frame) if r is None: return ip, ifname, proc = r hidden = bool(ifname) and ifname.startswith(HIDDEN_IFACES) if ifname and ifname not in self.ifaces: self.ifaces.add(ifname) if self.hub.echo: note = " (inside a VPN tunnel, hidden)" if hidden and not self.include_tunnels else "" print(" seeing traffic on %s%s" % (ifname, note), flush=True) if hidden and not self.include_tunnels: return pkt = parse_ip(ip) if pkt is None: return proto, src, dst, l4 = pkt app = None if proc in QUIET_PROCS else proc if proto == 17 and len(l4) >= 8: sport, dport = struct.unpack(">HH", l4[:4]) self.udp(src, sport, dst, dport, l4[8:], app) elif proto == 6 and len(l4) >= 20: sport, dport, seq = struct.unpack(">HHI", l4[:8]) doff = (l4[12] >> 4) * 4 data = l4[doff:] if data: self.tcp_segment(src, sport, dst, dport, seq, data, app) now = time.time() if now - self.last_gc > 5: self.gc(now) def gc(self, now): self.last_gc = now self.tcp = {k: v for k, v in self.tcp.items() if now - v["t"] < 15} self.quic = {k: v for k, v in self.quic.items() if now - v["t"] < 15} def emit(self, name, via, app=None, ech=False): host = clean_host(name) if host: self.hub.domain(host, via, app, ech) def udp(self, src, sport, dst, dport, data, app): if dport == 53: for name in dns_query_names(data): self.emit(name, "DNS") return if data and data[0] & 0xC0 == 0xC0: # QUIC long header r = quic_client_initial(data) if r: self.quic_frames((src, sport, r[0]), r[1], app) return kind = wireguard_kind(data) if kind: self.wireguard(src, sport, dst, dport, len(data)) def quic_frames(self, key, frames, app): st = self.quic.get(key) if st is None: st = self.quic[key] = {"frags": {}, "t": time.time(), "done": False} if st["done"]: return st["t"] = time.time() for off, chunk in frames: st["frags"][off] = chunk buf = bytearray() for off in sorted(st["frags"]): chunk = st["frags"][off] if off > len(buf): break if off + len(chunk) > len(buf): buf += chunk[len(buf) - off:] res = parse_client_hello(bytes(buf)) if res is None: return sni, ech, complete = res if sni: st["done"] = True self.emit(sni, "QUIC", app, ech) elif complete: st["done"] = True def tcp_segment(self, src, sport, dst, dport, seq, data, app): key = (src, sport, dst, dport) st = self.tcp.get(key) if st is None: if len(data) < 6 or data[0] != 0x16 or data[1] != 0x03 or data[5] != 0x01: return st = self.tcp[key] = {"buf": bytearray(data), "next": (seq + len(data)) & 0xFFFFFFFF, "t": time.time()} else: if seq != st["next"]: return st["buf"] += data st["next"] = (seq + len(data)) & 0xFFFFFFFF st["t"] = time.time() res = parse_client_hello(tls_handshake_from_stream(st["buf"])) if res is None: self.tcp.pop(key, None) return sni, ech, complete = res if sni: self.tcp.pop(key, None) self.emit(sni, "TLS", app, ech) elif complete or len(st["buf"]) > 32768: self.tcp.pop(key, None) def wireguard(self, src, sport, dst, dport, size): if _is_global(dst) != _is_global(src): peer, port = (dst, dport) if _is_global(dst) else (src, sport) elif 51820 in (sport, dport): peer, port = (dst, dport) if dport == 51820 else (src, sport) else: peer, port = (dst, dport) if dport <= sport else (src, sport) st = self.wg.setdefault(peer, {"packets": 0, "bytes": 0, "sent": 0.0}) st["packets"] += 1 st["bytes"] += size now = time.time() if now - st["sent"] > 1.5: st["sent"] = now self.hub.tunnel(peer, port, st["packets"], st["bytes"]) # -------------------------------------------------------------------------- # Dashboard # -------------------------------------------------------------------------- DASHBOARD = r""" What your provider sees
What your provider sees
Connecting
00:00:00

Every site name your device has just revealed, newest first

Waiting for traffic. Open any site or app on the phone and its name will appear here within a second.

Only an encrypted tunnel

Your provider can see that you are connected to , and how much data flows. No site names at all.

""" class Handler(BaseHTTPRequestHandler): hub = None server_version = "isp-sees" def log_message(self, *args): pass def do_GET(self): if self.path in ("/", "/index.html") or self.path.startswith("/?"): body = DASHBOARD.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) elif self.path == "/events": self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-store") self.send_header("Connection", "keep-alive") self.end_headers() q, snap = self.hub.subscribe() try: self.wfile.write(b"event: snapshot\ndata: " + json.dumps(snap).encode() + b"\n\n") self.wfile.flush() while True: try: ev = q.get(timeout=15) self.wfile.write(b"data: " + json.dumps(ev).encode() + b"\n\n") except queue.Empty: self.wfile.write(b": keepalive\n\n") self.wfile.flush() except (BrokenPipeError, ConnectionResetError, OSError): pass finally: self.hub.unsubscribe(q) elif self.path == "/state": _q, snap = self.hub.subscribe() self.hub.unsubscribe(_q) body = json.dumps(snap).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) else: self.send_error(404) def do_POST(self): if self.path == "/clear": self.hub.clear() self.send_response(204) self.end_headers() else: self.send_error(404) def start_server(hub, port): Handler.hub = hub for p in range(port, port + 20): try: srv = ThreadingHTTPServer(("127.0.0.1", p), Handler) break except OSError: continue else: die("Couldn't find a free port near %d." % port) srv.daemon_threads = True threading.Thread(target=srv.serve_forever, daemon=True).start() return "http://127.0.0.1:%d/" % srv.server_address[1] def open_browser(url): user = os.environ.get("SUDO_USER") try: if sys.platform == "darwin": cmd = ["open", url] if user and os.geteuid() == 0: cmd = ["sudo", "-u", user, "open", url] subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) else: webbrowser.open(url) except Exception: pass # -------------------------------------------------------------------------- # Capture sources # -------------------------------------------------------------------------- def die(msg): print("\n" + msg + "\n", file=sys.stderr) sys.exit(1) def ensure_root(): if os.geteuid() != 0: print("Capturing traffic needs admin rights. Enter your Mac password if asked.") os.execvp("sudo", ["sudo", sys.executable, os.path.abspath(__file__)] + sys.argv[1:]) def find_rvictl(): for p in (shutil.which("rvictl"), "/Library/Apple/usr/bin/rvictl", "/usr/bin/rvictl"): if p and os.path.exists(p): return p return None def find_iphone(): """Return (udid, name) of a cable-connected iPhone, or (None, None).""" try: cmd = ["xcrun", "xctrace", "list", "devices"] user = os.environ.get("SUDO_USER") if user and os.geteuid() == 0: cmd = ["sudo", "-u", user] + cmd out = subprocess.run(cmd, capture_output=True, text=True, timeout=90).stdout section, found = "", [] for line in out.splitlines(): s = line.strip() if s.startswith("=="): section = s continue if section == "== Devices ==": m = re.match(r"^(.*?) \(([\d.]+)\) \(([0-9A-Fa-f-]{20,})\)$", s) if m: found.append((m.group(3), m.group(1))) found.sort(key=lambda x: ("iphone" not in x[1].lower(), "watch" in x[1].lower())) if found: return found[0] except Exception: pass try: out = subprocess.run(["system_profiler", "SPUSBDataType", "SPUSBHostDataType"], capture_output=True, text=True, timeout=60).stdout in_phone = False for line in out.splitlines(): if re.match(r"^\s*iP(hone|ad)\b.*:\s*$", line): in_phone = True continue if in_phone: m = re.search(r"Serial Number:\s*([0-9A-Fa-f]{24,40})\s*$", line) if m: sn = m.group(1) if len(sn) == 24: sn = sn[:8] + "-" + sn[8:] return sn, "iPhone" except Exception: pass return None, None def start_iphone_mirror(udid): rvictl = find_rvictl() if not rvictl: die("Can't find rvictl, Apple's tool for mirroring an iPhone's traffic.\n" "Install Xcode from the App Store, open it once so it finishes setting up,\n" "then run this again.") name = "iPhone" if not udid: print("Looking for an iPhone connected by cable...") udid, name = find_iphone() if not udid: die("No iPhone found. Check that:\n" " 1. the iPhone is connected with a cable and unlocked,\n" " 2. you tapped \"Trust\" on the iPhone,\n" " 3. Xcode has been opened at least once.\n" "You can also pass the ID yourself: --udid 00008110-XXXXXXXXXXXXXXXX\n" "(Finder > your iPhone > click the text under its name until UDID appears)") print("Found %s (%s). Starting the traffic mirror..." % (name, udid)) r = subprocess.run([rvictl, "-s", udid], capture_output=True, text=True) out = (r.stdout or "") + (r.stderr or "") m = re.search(r"interface (rvi\d+)", out) if "FAILED" in out.upper() or (r.returncode != 0 and not m): die("Apple's mirror tool refused to start:\n " + out.strip() + "\n" "Unlock the iPhone, unplug and replug the cable, tap \"Trust\", and try again.") iface = m.group(1) if m else "rvi0" atexit.register(lambda: subprocess.run([rvictl, "-x", udid], capture_output=True)) for _ in range(40): if subprocess.run(["ifconfig", iface], capture_output=True).returncode == 0: break time.sleep(0.25) return iface, name def default_iface(): try: out = subprocess.run(["route", "-n", "get", "default"], capture_output=True, text=True).stdout m = re.search(r"interface:\s*(\S+)", out) if m: return m.group(1) except Exception: pass return "en0" def capture_live(iface, analyzer, hub): tcpdump = shutil.which("tcpdump") or "/usr/sbin/tcpdump" proc = subprocess.Popen([tcpdump, "-i", iface, "-n", "-U", "-s", "0", "-w", "-"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0) errors = [] def pump(): for raw in iter(proc.stderr.readline, b""): line = raw.decode("utf-8", "replace").strip() m = re.search(r"link-type (\S+)", line) if m: hub.set_status(link="%s, %s" % (iface, m.group(1))) elif line: errors.append(line) threading.Thread(target=pump, daemon=True).start() try: for linktype, ts, frame in read_capture(proc.stdout): analyzer.feed(linktype, ts, frame) finally: if proc.poll() is None: proc.terminate() if errors: die("Capture stopped:\n " + "\n ".join(errors[-5:])) def replay(path, analyzer, speed): with open(path, "rb") as f: first_ts, start = None, time.time() for linktype, ts, frame in read_capture(f): if speed > 0: if first_ts is None: first_ts = ts wait = start + (ts - first_ts) / speed - time.time() if wait > 0: time.sleep(min(wait, 5)) analyzer.feed(linktype, ts, frame) # -------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser(description="See what your internet provider can see, live.") ap.add_argument("--udid", help="iPhone UDID, if it isn't found automatically") ap.add_argument("--mac", action="store_true", help="watch this Mac's own traffic instead of an iPhone") ap.add_argument("--iface", help="capture a specific network interface, e.g. en0 or bridge100") ap.add_argument("--pcap", help="replay a saved .pcap/.pcapng file instead of capturing") ap.add_argument("--speed", type=float, default=1.0, help="replay speed, 0 = as fast as possible") ap.add_argument("--port", type=int, default=8765, help="dashboard port (default 8765)") ap.add_argument("--no-browser", action="store_true", help="don't open the dashboard automatically") ap.add_argument("--include-tunnels", action="store_true", help="also show names seen inside VPN interfaces (off by default)") ap.add_argument("--quiet", action="store_true", help="don't print new sites in the terminal") args = ap.parse_args() if sys.version_info < (3, 7): die("Needs Python 3.7 or newer.") hub = Hub() hub.echo = not args.quiet analyzer = Analyzer(hub, include_tunnels=args.include_tunnels) if args.pcap: source = "a replay of " + os.path.basename(args.pcap) iface = None else: if sys.platform != "darwin" and not args.iface: die("Live iPhone mode needs a Mac. On other systems use --iface .") ensure_root() if args.iface: iface, source = args.iface, args.iface elif args.mac: iface = default_iface() source = "this Mac (%s)" % iface else: iface, name = start_iphone_mirror(args.udid) source = "%s over USB" % name hub.set_status(source=source, started=time.time()) url = start_server(hub, args.port) threading.Thread(target=hub.stats_loop, daemon=True).start() print("\nDashboard: %s" % url) print("Source: %s" % source) if not HAVE_CRYPTO: print("Note: QUIC (HTTP/3) names are off. Turn them on with: pip3 install --user cryptography") print("Stop with Ctrl+C.\n") if not args.no_browser: open_browser(url) try: if args.pcap: replay(args.pcap, analyzer, args.speed) print("\nReplay finished. The dashboard stays open until you press Ctrl+C.") while True: time.sleep(3600) else: capture_live(iface, analyzer, hub) except KeyboardInterrupt: print("\nStopped.") if __name__ == "__main__": main()