286 lines
14 KiB
Python
286 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""宿主↔iframe postMessage 信道双边探针(批② 遗留债专项修 · Phase 1 取证工具)。
|
||
|
||
诊断目标(mc-batch1-merge-shipped 遗留债):
|
||
host→iframe (host_to_game) 回包方向运行时未闭合——idle loadState 的宿主回包
|
||
从未到达 iframe 侧 inject 应答处理器;疑似同断 ad/pay(同回包机制)。
|
||
|
||
方法(不改任何源码,纯 CDP 注入):
|
||
Page.addScriptToEvaluateOnNewDocument 向【每个 frame】(含 srcdoc 游戏 iframe)
|
||
的文档创建期注入两个探针,对 channel='wanxiang-game-sdk' 的信封:
|
||
1) RECV 探针:window 'message' 监听(文档首脚本注册,先于页面自身监听器),
|
||
记录「消息事件实际在哪个 realm 触发」+ direction/type/requestId/origin;
|
||
2) CALL 探针:包裹 window.postMessage,记录「postMessage 调用实际落在哪个
|
||
realm 的 Window 上」+ targetOrigin 实参——宿主经 contentWindow 代理调用时,
|
||
属性查找落在 iframe realm,故 CALL@SUB 出现与否可证回包是否打到了
|
||
【当前存活的】iframe 文档(打到失效代理/错误窗口则 CALL@SUB 缺失)。
|
||
|
||
证据判读(四锚点,按 storage 类型):
|
||
CALL@TOP(game_to_host) = 游戏发出 loadState 请求(parent.postMessage 落顶层)
|
||
RECV@TOP(game_to_host) = 宿主 window 收到请求(bridge 入口)
|
||
CALL@SUB(host_to_game) = 宿主回包调用落在当前 iframe realm(bridge.post 出口)
|
||
RECV@SUB(host_to_game) = iframe window 收到回包(inject 监听器入口)
|
||
缺哪一环,断点就在哪一环之前。
|
||
|
||
用法(mini-desktop,Chrome CDP 9222 + 前端 :4173 在线):
|
||
python3 probe_bridge_channel.py --url http://localhost:4173/play/9108 [--wait 12]
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
|
||
# 复用既有 CDP 会话封装(同目录 player_cdp.py:连接/调用/事件泵/console 捕获)
|
||
from player_cdp import CdpSession, _log
|
||
|
||
# 注入到所有 frame 的探针脚本(只读取证 + 透明包裹,绝不改变消息内容与投递行为)
|
||
PROBE_JS = r"""
|
||
(function () {
|
||
if (window.__wxprobeInstalled) { return; } // 幂等:同文档只装一次
|
||
window.__wxprobeInstalled = true;
|
||
var TAG = (window === window.top) ? 'TOP' : 'SUB';
|
||
function brief(d) {
|
||
try {
|
||
var o = { ty: d && d.type, dir: d && d.direction, rid: (d && d.requestId) || null };
|
||
// storage 回包附带 value 形态指示(obj=有存档对象 / null=无存档)——离线补发往返验收用
|
||
if (d && d.type === 'storage' && d.direction === 'host_to_game') {
|
||
var v = d.payload && d.payload.value;
|
||
o.val = (v && typeof v === 'object') ? 'obj' : 'null';
|
||
}
|
||
return JSON.stringify(o);
|
||
}
|
||
catch (e) { return '"?"'; }
|
||
}
|
||
// RECV 探针:本 realm 的 message 事件实际触达记录(文档首脚本注册,先于页面监听器)
|
||
window.addEventListener('message', function (ev) {
|
||
try {
|
||
var d = ev.data;
|
||
if (d && typeof d === 'object' && d.channel === 'wanxiang-game-sdk') {
|
||
console.log('[WXPROBE] RECV@' + TAG + ' ' + brief(d) + ' origin=' + ev.origin);
|
||
}
|
||
} catch (e) { /* 取证绝不影响页面 */ }
|
||
}, false);
|
||
// CALL 探针:包裹本 realm 的 window.postMessage——跨 realm 经 WindowProxy 调用时
|
||
// 属性查找落到目标 realm,故能证明「调用打到了哪个(存活)窗口」
|
||
try {
|
||
var op = window.postMessage.bind(window);
|
||
window.postMessage = function (m, t, x) {
|
||
try {
|
||
if (m && typeof m === 'object' && m.channel === 'wanxiang-game-sdk') {
|
||
console.log('[WXPROBE] CALL@' + TAG + ' ' + brief(m) + ' targetOrigin=' + String(t));
|
||
}
|
||
} catch (e) { }
|
||
return (arguments.length >= 3) ? op(m, t, x) : op(m, t);
|
||
};
|
||
} catch (e) { console.log('[WXPROBE] CALL 探针安装失败@' + TAG + ': ' + e); }
|
||
// 文档代际标记:每个新文档报一次身份(区分 iframe 文档被重建/导航了几代)
|
||
var docId = Math.random().toString(36).slice(2, 8);
|
||
window.__wxprobeDocId = docId;
|
||
console.log('[WXPROBE] DOCBOOT@' + TAG + ' docId=' + docId + ' href=' + String(location.href).slice(0, 50));
|
||
// 仅 TOP:①iframe 元素增删监视(DOMContentLoaded 后装——document-start 时 documentElement 尚 null)
|
||
// ②contentWindow 读取监听:每次读取=应用侧(attachBridge 等)取代理的时刻;
|
||
// 给元素编号 eid,时间线可证「bridge 拿的是哪一代元素的代理」。
|
||
if (TAG === 'TOP') {
|
||
var installObserver = function () {
|
||
try {
|
||
new MutationObserver(function (muts) {
|
||
muts.forEach(function (mu) {
|
||
var scan = function (nodes, act) {
|
||
for (var i = 0; i < nodes.length; i++) {
|
||
var n = nodes[i];
|
||
if (!n) continue;
|
||
if (n.tagName === 'IFRAME') { console.log('[WXPROBE] IFRAME-' + act + ' ' + (n.__wxeid || '未编号')); }
|
||
else if (n.querySelectorAll && n.querySelectorAll('iframe').length) {
|
||
var fs = n.querySelectorAll('iframe');
|
||
for (var j = 0; j < fs.length; j++) { console.log('[WXPROBE] IFRAME-' + act + '(嵌套) ' + (fs[j].__wxeid || '未编号')); }
|
||
}
|
||
}
|
||
};
|
||
scan(mu.addedNodes || [], 'ADDED');
|
||
scan(mu.removedNodes || [], 'REMOVED');
|
||
});
|
||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||
} catch (e) { console.log('[WXPROBE] 观察器安装失败: ' + e); }
|
||
};
|
||
if (document.readyState === 'loading') { window.addEventListener('DOMContentLoaded', installObserver); }
|
||
else { installObserver(); }
|
||
try {
|
||
var desc = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow');
|
||
var eidSeq = 0;
|
||
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
|
||
configurable: true,
|
||
get: function () {
|
||
if (!this.__wxeid) { this.__wxeid = 'el' + (++eidSeq); }
|
||
var w = desc.get.call(this);
|
||
var docId = null;
|
||
try { docId = w && w.__wxprobeDocId || null; } catch (e) { }
|
||
console.log('[WXPROBE] CWGET ' + this.__wxeid + ' connected=' + this.isConnected +
|
||
' null=' + (w === null) + ' docId=' + docId);
|
||
return w;
|
||
}
|
||
});
|
||
} catch (e) { console.log('[WXPROBE] contentWindow 监听安装失败: ' + e); }
|
||
}
|
||
})();
|
||
"""
|
||
|
||
# 判别实验脚本(pump 后在 TOP 上下文执行):
|
||
# a) 读取存活 iframe 状态(连接性 / contentWindow / 探针与文档代际)
|
||
# b) 经【存活 contentWindow 代理】手动注入一条 host_to_game storage 回包
|
||
# —— 若 RECV@SUB(probe-rid) 出现 = 通路本身健康,问题在 bridge 持有的引用/状态
|
||
DISCRIMINATE_JS = r"""
|
||
(function () {
|
||
var f = document.querySelector('iframe.gp-frame') || document.querySelector('iframe');
|
||
if (!f) { return 'no-iframe'; }
|
||
var cw = f.contentWindow;
|
||
var st = {
|
||
connected: !!f.isConnected,
|
||
cwNull: cw === null,
|
||
probeInIframe: false, iframeDocId: null, iframeHref: null,
|
||
srcdocLen: (f.getAttribute('srcdoc') || '').length
|
||
};
|
||
try { st.probeInIframe = !!(cw && cw.__wxprobeInstalled); } catch (e) { st.probeInIframe = 'x:' + e; }
|
||
try { st.iframeDocId = cw && cw.__wxprobeDocId || null; } catch (e) { }
|
||
try { st.iframeHref = cw && String(cw.location.href).slice(0, 40); } catch (e) { }
|
||
try {
|
||
cw.postMessage({ channel: 'wanxiang-game-sdk', type: 'storage', direction: 'host_to_game',
|
||
traceId: 'probe-manual', payload: { key: 'probe', value: null }, requestId: 'probe-rid' }, '*');
|
||
st.manualInject = 'sent';
|
||
} catch (e) { st.manualInject = 'fail:' + e; }
|
||
return JSON.stringify(st);
|
||
})()
|
||
"""
|
||
|
||
# 四锚点判读用(storage 类型):标签 → (探针位, direction)
|
||
ANCHORS = [
|
||
("①游戏发请求 CALL@TOP", "CALL@TOP", "game_to_host"),
|
||
("②宿主收请求 RECV@TOP", "RECV@TOP", "game_to_host"),
|
||
("③宿主回包落 iframe realm CALL@SUB", "CALL@SUB", "host_to_game"),
|
||
("④iframe 收回包 RECV@SUB", "RECV@SUB", "host_to_game"),
|
||
]
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="宿主↔iframe postMessage 信道双边探针")
|
||
ap.add_argument("--cdp", default="http://127.0.0.1:9222", help="CDP HTTP 端点")
|
||
ap.add_argument("--url", required=True, help="目标页(建议 idle 游戏 /play/<gameId>)")
|
||
ap.add_argument("--wait", type=float, default=12.0, help="取证时长秒(覆盖 loadState 5s 超时窗)")
|
||
ap.add_argument("--roundtrip", action="store_true",
|
||
help="离线补发往返剧本:点击启动→等节流存档(≥2s)→读 localStorage→刷新→验证回包 val=obj")
|
||
ap.add_argument("--storage-key", default="", help="往返剧本要核验的宿主侧 localStorage 键(wxgame:idle:<gid>:<vid>)")
|
||
args = ap.parse_args()
|
||
|
||
sess = CdpSession.open_new_page(args.cdp)
|
||
try:
|
||
sess.call("Page.enable")
|
||
sess.call("Runtime.enable")
|
||
sess.call("Network.enable") # 捕获取包/manifest 网络面(responseReceived/loadingFailed)
|
||
# 注入所有 frame(含 srcdoc 游戏 iframe);CDP 注入不受页面 CSP 限制
|
||
sess.call("Page.addScriptToEvaluateOnNewDocument", {"source": PROBE_JS})
|
||
_log(f"导航:{args.url}")
|
||
sess.call("Page.navigate", {"url": args.url})
|
||
sess.pump(args.wait)
|
||
|
||
# —— 离线补发往返剧本(--roundtrip)——
|
||
if args.roundtrip:
|
||
# ① 点击画布中心启动游戏(idle 自动产出仅 started 后累加;存档节流 ≥2s)
|
||
for ev in ("mousePressed", "mouseReleased"):
|
||
sess.call("Input.dispatchMouseEvent", {
|
||
"type": ev, "x": 210, "y": 450, "button": "left", "clickCount": 1})
|
||
_log("已点击启动,等待节流存档(6s)…")
|
||
sess.pump(6.0)
|
||
# ② 核验宿主侧 localStorage 已落存档
|
||
if args.storage_key:
|
||
r = sess.call("Runtime.evaluate", {
|
||
"expression": f"localStorage.getItem({json.dumps(args.storage_key)})",
|
||
"returnByValue": True})
|
||
saved = r.get("result", {}).get("value")
|
||
print(f"==== 往返① 存档落 localStorage ====\n{args.storage_key} = {str(saved)[:200]}")
|
||
# ③ 刷新页面 → 第二局 loadState 应回包 val=obj(离线补发链路闭合的判据)
|
||
_log("刷新页面,验证第二局读档…")
|
||
sess.call("Page.reload")
|
||
sess.pump(8.0)
|
||
|
||
# —— 判别实验:iframe 状态 + 经存活代理手动注入 host_to_game ——
|
||
try:
|
||
r = sess.call("Runtime.evaluate", {"expression": DISCRIMINATE_JS, "returnByValue": True})
|
||
val = r.get("result", {}).get("value", r)
|
||
print(f"==== 判别实验(iframe 状态 + 手动注入) ====\n{val}")
|
||
except Exception as e: # noqa: BLE001 - 取证路径,异常只记录
|
||
print(f"判别实验执行失败:{e}")
|
||
sess.pump(3.0) # 等手动注入的 RECV@SUB 浮出
|
||
|
||
# —— 证据输出:全部 WXPROBE 行(原始顺序)——
|
||
probe_lines = [c["text"] for c in sess.console_entries if "[WXPROBE]" in c.get("text", "")]
|
||
print("==== WXPROBE 原始证据(按时间序) ====")
|
||
for line in probe_lines:
|
||
print(line)
|
||
|
||
# —— 四锚点判读(仅 storage 类型)——
|
||
print("\n==== 四锚点判读(type=storage) ====")
|
||
verdicts = {}
|
||
for label, tag, direction in ANCHORS:
|
||
hit = any(
|
||
tag in ln and '"ty": "storage"' in ln.replace("'", '"') or
|
||
(tag in ln and '"ty":"storage"' in ln and f'"dir":"{direction}"' in ln)
|
||
for ln in probe_lines
|
||
)
|
||
# 宽松匹配兜底:tag 与 storage 与 direction 三关键字同行即认;
|
||
# 排除判别实验手动注入的 probe-rid(锚点只认应用自身的消息)
|
||
hit = hit or any(
|
||
(tag in ln) and ("storage" in ln) and (direction in ln) and ("probe-rid" not in ln)
|
||
for ln in probe_lines
|
||
)
|
||
verdicts[tag] = hit
|
||
print(f"{label}: {'✅ 观测到' if hit else '❌ 未观测到'}")
|
||
|
||
# 断点定位结论(首个缺失锚点)
|
||
print("\n==== 断点定位 ====")
|
||
chain = ["CALL@TOP", "RECV@TOP", "CALL@SUB", "RECV@SUB"]
|
||
missing = [t for t in chain if not verdicts.get(t)]
|
||
if not missing:
|
||
print("四锚点全通:信道链路完整——问题在 iframe 应答处理器内部过滤(查 type/requestId 守卫)")
|
||
else:
|
||
print(f"首个缺失锚点:{missing[0]}(断点在它之前的一跳)")
|
||
|
||
# 附:页面级异常与非探针 console(上下文佐证)
|
||
if sess.exceptions:
|
||
print("\n==== 页面异常 ====")
|
||
for e in sess.exceptions[:10]:
|
||
print(e.get("text", "")[:200])
|
||
|
||
# 附:全量 console(游戏未起时定位前置失败必看)
|
||
print("\n==== 全量 console(前 40 条) ====")
|
||
for c in sess.console_entries[:40]:
|
||
print(f"[{c.get('type','')}] {c.get('text','')[:220]}")
|
||
|
||
# 附:网络失败 + 非 2xx 响应(取包/manifest 失败定位)
|
||
bad = [r for r in sess.network_responses if int(r.get("status", 0)) >= 400]
|
||
if bad or sess.network_failures:
|
||
print("\n==== 网络异常 ====")
|
||
for r in bad[:10]:
|
||
print(f"HTTP {r.get('status')} {r.get('url','')[:160]}")
|
||
for f in sess.network_failures[:10]:
|
||
print(f"FAIL {f.get('errorText','')} {f.get('url','')[:160]}")
|
||
|
||
# 附:页面终态(路由是否真到 play、可见文案)
|
||
try:
|
||
r = sess.call("Runtime.evaluate", {
|
||
"expression": "location.href + ' || ' + document.body.innerText.replace(/\\n+/g,' | ').slice(0,200)",
|
||
"returnByValue": True})
|
||
print("\n==== 页面终态 ====")
|
||
# call() 返回 CDP 应答的 result 字段:Runtime.evaluate 的值在 result.result.value
|
||
val = r.get("result", {}).get("value", "") if "result" in r else r
|
||
print(str(val)[:300])
|
||
except Exception as e: # noqa: BLE001 - 取证收尾,异常只记录
|
||
print(f"页面终态读取失败:{e}")
|
||
return 0
|
||
finally:
|
||
sess.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|