#!/usr/bin/env python3 """Decode the SAND master-server WebSocket stream out of a packet capture. The master server is a .NET `ClientWebSocket` to `ws://.hologryph.com/gameclient/` (port 80, **cleartext** — confirmed from the IL2CPP metadata: only `ws://` literals, no `wss`). Messages are request/response `OperationResult` plus server-push `IClientEvent`s, serialized by the game's `IDataSerializer` (JsonDataSerializer / BsonDataSerializer — JSON is the likely default; this decoder tries JSON, BSON and MessagePack so the exact encoding doesn't block us). What it does: 1. groups packets into TCP streams, reassembles each direction by sequence number 2. finds the WebSocket HTTP upgrade (`GET /gameclient/ ... Upgrade: websocket` / `101`) 3. parses RFC-6455 frames (handles masking + continuation), per direction 4. decodes each message payload (JSON -> BSON -> MessagePack -> hex) and prints it 5. tags messages whose shape matches a known DTO (ServerDto, RegionInfo, CompartmentDefinitionDto, ResearchNode*, OperationResult, IClientEvent...) Because it's cleartext, NO MITM/cert is needed for this channel — just capture port 80. Usage: venv/bin/python reverse/ws_scrape.py [--port 80] [--host hologryph] [--out extracted/master_ws.json] """ import sys, json, struct, argparse from collections import defaultdict from scapy.all import rdpcap, IP, IPv6, TCP, Raw try: import msgpack except Exception: msgpack = None # ---- known DTO field-sets, to label decoded objects (from il2cpp/dump.cs) ---- DTO_SIGNATURES = { "ServerDto": {"Name", "Description", "UpTime", "Ip", "Port"}, "WorldEndpointData": {"worldName", "address", "port", "custom"}, "CompartmentDefinitionDto": {"EpbId", "HP", "Weight", "Properties", "CrownPrice"}, "ResearchNodeJsonDto": {"Id", "Tier", "ResearchPrice", "RequiredNodesIds", "DependentNodesIds"}, "ResearchTreeJsonDto": {"Roots", "Nodes"}, "ItemDto": {"DefinitionName", "SellPrice", "Outfitable", "IsLarge"}, "ShopItemDto": {"DefinitionName", "BuyPrice", "Amount"}, "PriceDto": {"ItemDefinition", "Amount"}, "UserDto": {"Id", "DatabaseId"}, "OperationResult": {"IsSucceed", "Error", "Status"}, "LoginProcessResult": {"IsSuccess", "Error"}, } def label_obj(o, depth=0): """Best-effort DTO name for a decoded dict (and recurse a little).""" names = [] if isinstance(o, dict): keys = set(o.keys()) for name, sig in DTO_SIGNATURES.items(): if sig <= keys: names.append(name) if depth < 2: for v in o.values(): names += label_obj(v, depth + 1) elif isinstance(o, list) and o and depth < 2: names += label_obj(o[0], depth + 1) return names # ---------- minimal BSON decoder (Newtonsoft.Json.Bson wire format) ---------- def _bson_cstring(b, i): j = b.index(0, i) return b[i:j].decode("utf-8", "replace"), j + 1 def _bson_doc(b, i): ln = struct.unpack_from("= 5: try: ln = struct.unpack_from("= 0 else 0 b = stream n = len(b) cur_op = None cur = bytearray() while i + 2 <= n: b0 = b[i]; b1 = b[i + 1] fin = b0 & 0x80 op = b0 & 0x0F masked = b1 & 0x80 ln = b1 & 0x7F i += 2 if ln == 126: if i + 2 > n: break ln = struct.unpack_from(">H", b, i)[0]; i += 2 elif ln == 127: if i + 8 > n: break ln = struct.unpack_from(">Q", b, i)[0]; i += 8 mask = b"" if masked: if i + 4 > n: break mask = b[i:i + 4]; i += 4 if i + ln > n: break data = bytearray(b[i:i + ln]); i += ln if masked: for k in range(len(data)): data[k] ^= mask[k & 3] if op == 0x8: # close break if op in (0x9, 0xA): # ping/pong continue if op == 0x0: # continuation cur += data else: if cur_op is not None: pass cur_op = op cur = bytearray(data) if fin and cur_op in (0x1, 0x2): yield cur_op, bytes(cur) cur_op = None cur = bytearray() def main(): ap = argparse.ArgumentParser() ap.add_argument("pcap") ap.add_argument("--port", type=int, default=80) ap.add_argument("--host", default="hologryph", help="handshake Host substring filter ('' = any)") ap.add_argument("--out", default=None) args = ap.parse_args() pk = rdpcap(args.pcap) messages = [] nstreams = 0 for key, cbytes, sbytes in reassemble(pk, args.port, args.host): nstreams += 1 # show the handshake request line + host line0 = cbytes.split(b"\r\n", 1)[0].decode(errors="replace") host = "" for ln in cbytes[:cbytes.find(b"\r\n\r\n") + 4].split(b"\r\n"): if ln.lower().startswith(b"host:"): host = ln.decode(errors="replace") print("### WS stream %s %s %s" % (key, line0, host)) for direction, stream in (("S->C", sbytes), ("C->S", cbytes)): for op, payload in ws_frames(stream): enc, obj = try_decode(payload) rec = {"stream": str(key), "dir": direction, "opcode": ("text" if op == 1 else "binary"), "encoding": enc, "len": len(payload)} if obj is not None: rec["dto"] = sorted(set(label_obj(obj))) rec["data"] = obj else: rec["raw_prefix"] = payload[:48].hex() messages.append(rec) tag = ("[" + ",".join(rec.get("dto", [])) + "] ") if rec.get("dto") else "" preview = json.dumps(obj, default=str)[:160] if obj is not None else rec["raw_prefix"] print(" %s %-7s %-8s %s%s" % (direction, rec["opcode"], enc, tag, preview)) print("\n%d WS stream(s), %d message(s)" % (nstreams, len(messages))) if nstreams == 0: print("No WebSocket streams found. If the backend was online, check --port/--host, " "or the capture may not span the master-server connection.") if args.out: json.dump({"_source": args.pcap, "messages": messages}, open(args.out, "w"), indent=1, default=str, ensure_ascii=False) print("wrote", args.out) if __name__ == "__main__": main()