2026-06-11 02:48:47 +00:00

286 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 realmbridge.post 出口)
RECV@SUB(host_to_game) = iframe window 收到回包inject 监听器入口)
缺哪一环,断点就在哪一环之前。
用法mini-desktopChrome 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 游戏 iframeCDP 注入不受页面 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())