#!/usr/bin/env python3 """Quick triage of a SAND network capture: every host the client touched, in order, with the bits we care about highlighted. Pulls: - DNS queries (order = startup sequence; flush DNS first for a clean list) - TLS SNI (HTTPS hostnames even when DNS was cached) - TCP/UDP endpoints and flags the three backends we care about: - .playfabapi.com -> prints the **PlayFab TitleId** (the one constant the REST scraper needs) - .hologryph.com -> the master-server region (ws://, port 80, cleartext) - sandconfigstorage.blob... -> the anonymous config CDN Usage: venv/bin/python reverse/capture_hosts.py """ import sys, re from scapy.all import rdpcap, DNS, DNSQR, DNSRR, IP, IPv6, TCP, UDP, Raw def tls_sni(b): """Extract SNI from a TLS ClientHello payload (bytes). None if not a ClientHello.""" try: if len(b) < 6 or b[0] != 0x16 or b[5] != 0x01: return None i = 5 + 4 + 2 + 32 # rec hdr + hs hdr + version + random i += 1 + b[i] # session id i += 2 + int.from_bytes(b[i:i + 2], "big") # cipher suites i += 1 + b[i] # compression methods end = i + 2 + int.from_bytes(b[i:i + 2], "big") i += 2 while i + 4 <= end: et = int.from_bytes(b[i:i + 2], "big") el = int.from_bytes(b[i + 2:i + 4], "big") i += 4 if et == 0: # server_name j = i + 2 nl = int.from_bytes(b[j + 1:j + 3], "big") return b[j + 3:j + 3 + nl].decode(errors="replace") i += el except Exception: return None return None PLAYFAB = re.compile(r"^([0-9A-Fa-f]{4,7})\.playfabapi\.com$") HOLOGRYPH = re.compile(r"^([a-z0-9-]+)\.hologryph\.com$", re.I) def main(): if len(sys.argv) < 2: sys.exit("usage: capture_hosts.py ") pk = rdpcap(sys.argv[1]) t0 = float(pk[0].time) dns_order, dns_seen = [], set() ip2host, snis = {}, {} tcp_first, udp_first = {}, {} for p in pk: if p.haslayer(DNS): d = p[DNS] try: qn = p[DNSQR].qname.decode(errors="replace").rstrip(".") except Exception: qn = None if qn and d.qr == 0 and not qn.endswith(".local") and qn != "wpad.localdomain": if qn not in dns_seen: dns_seen.add(qn) dns_order.append((float(p.time) - t0, qn)) if qn and d.qr == 1 and d.ancount: for k in range(d.ancount): rr = d.an[k] if rr.type in (1, 28): try: ip2host[str(rr.rdata)] = qn except Exception: pass ipl = p[IP] if p.haslayer(IP) else (p[IPv6] if p.haslayer(IPv6) else None) if ipl is None: continue if p.haslayer(TCP): t = p[TCP] if t.flags & 0x02 and not t.flags & 0x10: tcp_first.setdefault((ipl.dst, t.dport), float(p.time) - t0) if p.haslayer(Raw) and t.dport == 443: s = tls_sni(bytes(p[Raw].load)) if s: snis[ipl.dst] = s elif p.haslayer(UDP): u = p[UDP] if u.dport not in (53, 5353, 1900, 5355, 137) and u.sport not in (53, 5353): key = (ipl.dst, u.dport) if u.dport < u.sport else (ipl.src, u.sport) udp_first.setdefault(key, float(p.time) - t0) def label(ip): return snis.get(ip) or ip2host.get(ip, "") print("=== DNS queries (in order) ===") for ts, q in dns_order: print(" +%7.2fs %s" % (ts, q)) print("\n=== TCP destinations (first SYN) ===") for (ip, port), ts in sorted(tcp_first.items(), key=lambda x: x[1]): print(" +%7.2fs %-17s :%-5s %s" % (ts, ip, port, label(ip))) print("\n=== UDP destinations ===") for (ip, port), ts in sorted(udp_first.items(), key=lambda x: x[1])[:20]: print(" +%7.2fs %-17s :%-5s %s" % (ts, ip, port, label(ip))) # ---- the three backends we care about ---- print("\n=== BACKENDS DETECTED ===") allhosts = set(q for _, q in dns_order) | set(snis.values()) | set(ip2host.values()) found = False for h in sorted(allhosts): m = PLAYFAB.match(h) if m: print(" PlayFab host=%s ** TitleId = %s **" % (h, m.group(1).upper())) found = True elif HOLOGRYPH.match(h) and "gameclient" not in h: print(" Master server host=%s (region=%s, ws://80 cleartext)" % (h, HOLOGRYPH.match(h).group(1))) found = True elif "sandconfigstorage" in h: print(" Config CDN host=%s (anonymous HTTPS)" % h) found = True if not found: print(" (none of PlayFab / hologryph / config-blob seen — backend not contacted)") if __name__ == "__main__": main()