refactor(结构): 分层重组 + 清除全部废弃路线
做了什么:
- 包改为分层: tcaptcha/{protocol,telemetry,solvers,ocr,adapters,models,tests}
* protocol/ client.py transport.py proxy.py
* telemetry/ crypto.py values.py key.py opcodes.py
disasm.py program.py layout.py modules.py
* solvers/ text_click.py icon_click.py icon_click_rotate.py slider.py
* ocr/ engines.py slicer.py
* adapters/ one_ms.py
- tools/ 里原本是**包运行时依赖**的 tdc_{disasm,program,layout,modules}.py 并入 telemetry/
(放在 tools/ 属于分层倒置),tools/ 只留 CLI: tdc_key_pure.py / tdc_profile.py / cdp_*.js
- crypto.py 529 -> 340 行: 切除「成功样本模板回填」与「Node 插桩兜底」两条老路;
generate_telemetry 只剩 python_values 一种模式,传别的会直接报错并说明原因
- 求解器改名分清: solver.py -> solvers/icon_click.py(client 实际在用的 YOLO+ResNet18 ONNX),
icon_click_solver.py -> solvers/icon_click_rotate.py(仅测试在用,留作对照)
- pyproject: 去掉 ultralytics/onnx(训练栈已删),补上真正在用的 onnxruntime
删除了什么:
- 雨云整条线 + dist/rainyun_qinglong(104M 旧快照)
- 模板回填: models/success_collect_template.b64、tdc_worker.js、analyze_collect_layout.py
- 「补环境」时代浏览器 JS: chrome_fetch/manual_captcha/probe_captcha/solve_in_browser/tdc_browser
- 旧 VM 探索工具 10 个(tdc_collectgen/tdc_slotmap/tdc_lift/tdc_vm_run/tdc_query/tdc_probe 等)
+ 8 个基于它们的旧 verify 脚本
- 训练/标注/评估: train_classifier/train_detector/prepare_yolo/web_labeler/collector/
siamese/evaluate/benchmark_verify
- tcaptcha/core_js/(60 个分析产物)、TOOLCHAIN_REPORT.md(整篇在描述已删工具)
- 3 个已无任何引用的 PyTorch 权重:icon_classifier.pt 43M / yolo_detector.pt 5.9M /
siamese_matcher.pt 1.6M —— 线上只跑 onnx,tcaptcha/ 117M -> 66M
为什么:
- 项目后面要专门承载「滑块 / 文字点选 / 图标点选」三种求解器;扁平包 + 运行时依赖混在
tools/ + 两条已废弃的 collect 生成路线,会让后续扩展持续踩坑
怎么验证:
- .venv/bin/python -m tcaptcha.selftest 全绿
- scripts/verify/verify_offline.py 逐槽全等 + 分段几何一致
- scripts/verify/verify_values.py 4 份真机明文 152/152 逐槽全等
- scripts/verify/send_test.py --mode python_values 1ms 签到页文字点选 errorCode=0
- 回滚点: tag backup/pre-refactor-20261012
This commit is contained in:
@@ -1,118 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cdp_capture.js —— 用 CDP 抓**全部目标**(含 OOPIF iframe)的请求 + 响应体, 存成 JSON。
|
||||
*
|
||||
* 为什么需要它 (而不是 cdp_net_capture.js):
|
||||
* 1. 腾讯验证码 iframe (turing.captcha.gtimg.com) 与页面 (1ms.run) **不同站**,
|
||||
* Chrome 站点隔离下它是独立的 target; 只 attach 顶层页面会**漏掉**验证码自己的
|
||||
* prehandle / getcapbysig / verify 请求。
|
||||
* 2. 排查 errorCode=12 需要看**响应体**, cdp_net_capture.js 只记 requestWillBeSent。
|
||||
*
|
||||
* 零依赖 (Node >= 22 内置 WebSocket / fetch)。
|
||||
*
|
||||
* 用法:
|
||||
* node tools/cdp_capture.js --seconds 30 --url-substr cap_union --out /tmp/cap.json
|
||||
* node tools/cdp_capture.js --list
|
||||
*
|
||||
* 选项:
|
||||
* --port N 调试端口 (默认 9222)
|
||||
* --seconds N 抓取时长 (默认 20)
|
||||
* --url-substr S 只保留 url 含 S 的请求 (可选; 响应体只对命中的取)
|
||||
* --out PATH 输出 JSON (默认 /tmp/cdp_capture.json)
|
||||
* --bodies 额外抓响应体 (默认开)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
|
||||
function arg(name, def) {
|
||||
const i = process.argv.indexOf('--' + name);
|
||||
if (i < 0) return def;
|
||||
const v = process.argv[i + 1];
|
||||
return (v === undefined || v.startsWith('--')) ? true : v;
|
||||
}
|
||||
|
||||
class Cdp {
|
||||
constructor(ws, tag) { this.ws = ws; this.tag = tag; this.id = 0; this.waits = new Map(); this.handlers = []; }
|
||||
static async connect(url, tag) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws, tag);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
} else if (m.method) {
|
||||
for (const h of c.handlers) h(m);
|
||||
}
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}) {
|
||||
const id = ++this.id;
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise((res, rej) => this.waits.set(id, { res, rej }));
|
||||
}
|
||||
on(fn) { this.handlers.push(fn); }
|
||||
close() { try { this.ws.close(); } catch (e) {} }
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
(async () => {
|
||||
const port = parseInt(arg('port', '9222'), 10);
|
||||
if (arg('list', false)) {
|
||||
const t = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
||||
for (const x of t) console.log(`${x.type}\t${(x.url || '').slice(0, 110)}\t${x.id}`);
|
||||
return;
|
||||
}
|
||||
const seconds = parseInt(arg('seconds', '20'), 10);
|
||||
const substrArg = arg('url-substr', null);
|
||||
const substrs = substrArg && substrArg !== true ? String(substrArg).split(',').filter(Boolean) : null;
|
||||
const out = arg('out', '/tmp/cdp_capture.json');
|
||||
|
||||
const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json())
|
||||
.filter(t => t.type === 'page' || t.type === 'iframe');
|
||||
const conns = [];
|
||||
const records = [];
|
||||
const byReq = new Map();
|
||||
|
||||
for (const t of targets) {
|
||||
let cdp;
|
||||
try { cdp = await Cdp.connect(t.webSocketDebuggerUrl, t.url || t.id); } catch (e) { continue; }
|
||||
conns.push(cdp);
|
||||
const key = (p) => `${cdp.tag}|${p.requestId}`;
|
||||
cdp.on(m => {
|
||||
if (m.method === 'Network.requestWillBeSent') {
|
||||
const r = m.params.request;
|
||||
if (substrs && !substrs.some(s => r.url.includes(s))) return;
|
||||
const rec = {
|
||||
target: cdp.tag, url: r.url, method: r.method,
|
||||
headers: r.headers, postData: r.postData || null,
|
||||
ts: Date.now(),
|
||||
};
|
||||
records.push(rec);
|
||||
byReq.set(key(m.params), rec);
|
||||
} else if (m.method === 'Network.responseReceived') {
|
||||
const rec = byReq.get(key(m.params));
|
||||
if (rec) {
|
||||
rec.status = m.params.response.status;
|
||||
rec.respHeaders = m.params.response.headers;
|
||||
rec.mimeType = m.params.response.mimeType;
|
||||
}
|
||||
} else if (m.method === 'Network.loadingFinished') {
|
||||
const rec = byReq.get(key(m.params));
|
||||
if (rec && rec.status && !rec.body) {
|
||||
cdp.send('Network.getResponseBody', { requestId: m.params.requestId })
|
||||
.then(b => { rec.body = b.body; rec.base64 = !!b.base64Encoded; })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
await cdp.send('Network.enable');
|
||||
}
|
||||
console.error(`[cdp_capture] attached ${conns.length} targets, capturing ${seconds}s ...`);
|
||||
await sleep(seconds * 1000);
|
||||
for (const c of conns) c.close();
|
||||
fs.writeFileSync(out, JSON.stringify({ capturedAt: new Date().toISOString(), records }, null, 2));
|
||||
console.error(`[cdp_capture] wrote ${records.length} records -> ${out}`);
|
||||
})().catch(e => { console.error('CDP_ERROR: ' + e.message); process.exit(1); });
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cdp_click.js —— 用 CDP 在页面里派发**真实(trusted)鼠标事件** (点击 / 拖动)。
|
||||
* 用于驱动真实浏览器里的验证码 widget (合成 MouseEvent 会被 isTrusted 检测)。
|
||||
*
|
||||
* 用法:
|
||||
* # 依次点击若干点 (页面视口坐标)
|
||||
* node tools/cdp_click.js --match 1ms.run --clicks "520,700;300,600"
|
||||
* # 拖动: 从 (x,y) 水平/任意拖到 (x2,y2), 分 steps 步 (带中间移动)
|
||||
* node tools/cdp_click.js --match 1ms.run --drag "200,700,600,700,30"
|
||||
*
|
||||
* 注意: 坐标是**顶层页面视口**坐标 (iframe 内的元素要先换算成页面坐标)。
|
||||
*/
|
||||
function arg(name, def) {
|
||||
const i = process.argv.indexOf('--' + name);
|
||||
if (i < 0) return def;
|
||||
const v = process.argv[i + 1];
|
||||
return (v === undefined || v.startsWith('--')) ? true : v;
|
||||
}
|
||||
class Cdp {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); }
|
||||
static async connect(url) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
}
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}) {
|
||||
const id = ++this.id;
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise((res, rej) => this.waits.set(id, { res, rej }));
|
||||
}
|
||||
close() { try { this.ws.close(); } catch (e) {} }
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
(async () => {
|
||||
const port = parseInt(arg('port', '9222'), 10);
|
||||
const match = arg('match', null);
|
||||
const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json())
|
||||
.filter(t => t.type === 'page');
|
||||
const target = match ? targets.find(t => (t.url || '').includes(match)) : targets[0];
|
||||
if (!target) throw new Error('没有可用标签页');
|
||||
const cdp = await Cdp.connect(target.webSocketDebuggerUrl);
|
||||
|
||||
const click = async (x, y) => {
|
||||
const base = { x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1,
|
||||
buttons: 1, pointerType: 'mouse' };
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...base, buttons: 0 });
|
||||
await sleep(40);
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...base });
|
||||
await sleep(60);
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...base });
|
||||
await sleep(260);
|
||||
};
|
||||
|
||||
const clicks = arg('clicks', null);
|
||||
if (clicks && clicks !== true) {
|
||||
for (const p of String(clicks).split(';')) {
|
||||
const [x, y] = p.split(',').map(Number);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) await click(x, y);
|
||||
}
|
||||
console.log(JSON.stringify({ clicked: String(clicks).split(';').length, target: target.url }));
|
||||
}
|
||||
|
||||
const drag = arg('drag', null);
|
||||
if (drag && drag !== true) {
|
||||
const [x1, y1, x2, y2, steps] = String(drag).split(',').map(Number);
|
||||
const n = steps || 30;
|
||||
const base = { button: 'left', buttons: 1, pointerType: 'mouse', clickCount: 1 };
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: x1, y: y1, buttons: 0 });
|
||||
await sleep(80);
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: x1, y: y1, ...base });
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const t = i / n;
|
||||
const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||
const x = x1 + (x2 - x1) * ease + (Math.random() - 0.5) * 1.5;
|
||||
const y = y1 + (y2 - y1) * ease + (Math.random() - 0.5) * 1.5;
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: Math.round(x), y: Math.round(y), ...base });
|
||||
await sleep(12 + Math.random() * 10);
|
||||
}
|
||||
await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: x2, y: y2, ...base });
|
||||
console.log(JSON.stringify({ dragged: [x1, y1, x2, y2], steps: n, target: target.url }));
|
||||
}
|
||||
cdp.close();
|
||||
})().catch(e => { console.error('CDP_ERROR: ' + e.message); process.exit(1); });
|
||||
@@ -1,178 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cdp_module_probe.js —— 在真 Chrome 里**抓到每个采集器的真实取值**(不需要手工配平)。
|
||||
*
|
||||
* 背景(2026-10-12):
|
||||
* 腾讯验证码 iframe (captcha.gtimg.com) 与页面 (1ms.run) **不同站**,Chrome 站点隔离下
|
||||
* 它是独立 target —— 直接在页面里包 Function.prototype.apply **进不去**(实测:
|
||||
* 钩子装上时模块系统已经初始化完,__mods 恒为空)。
|
||||
*
|
||||
* 解法(CDP 标准做法):
|
||||
* Target.setAutoAttach(autoAttach, waitForDebuggerOnStart, flatten)
|
||||
* -> 新的 iframe target 一创建就**暂停在任何脚本执行之前**;
|
||||
* -> 此时 Page.addScriptToEvaluateOnNewDocument 注册钩子,再 runIfWaitingForDebugger 放行;
|
||||
* -> tdc.js 执行时钩子已就位,模块工厂的 require/get 全被记账。
|
||||
*
|
||||
* 产出: {url, mods: [[module_id, get()返回值], ...], reqs: [module_id,...]}
|
||||
* 配合同一会话的 tdc.js 静态分析(tdc_modules.analyze)即可得到「语义名 -> 真实取值」,
|
||||
* 无需任何人工对齐。
|
||||
*
|
||||
* 用法:
|
||||
* node tools/cdp_module_probe.js --aid 195672344 --out /tmp/1ms_bridge/modvals.json
|
||||
*/
|
||||
const fs = require('fs');
|
||||
|
||||
function arg(name, def) {
|
||||
const i = process.argv.indexOf('--' + name);
|
||||
if (i < 0) return def;
|
||||
const v = process.argv[i + 1];
|
||||
return (v === undefined || v.startsWith('--')) ? true : v;
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
class Cdp {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.handlers = []; }
|
||||
static async connect(url) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
} else if (m.method) { for (const h of c.handlers) h(m); }
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}, sessionId) {
|
||||
const id = ++this.id;
|
||||
const msg = { id, method, params };
|
||||
if (sessionId) msg.sessionId = sessionId;
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
return new Promise((res, rej) => this.waits.set(id, { res, rej }));
|
||||
}
|
||||
on(fn) { this.handlers.push(fn); }
|
||||
close() { try { this.ws.close(); } catch (e) {} }
|
||||
}
|
||||
|
||||
// 在任何页面脚本之前注入的钩子:记账「模块工厂 require 了谁」+「每个模块 .get() 返回什么」
|
||||
const HOOK = `(() => {
|
||||
if (window.__mp) return;
|
||||
window.__mp = true;
|
||||
window.__mods = [];
|
||||
window.__reqs = [];
|
||||
const oa = Function.prototype.apply;
|
||||
Function.prototype.apply = function (t, a) {
|
||||
try {
|
||||
if (a && a.length >= 3 && typeof a[a.length - 1] === 'function') {
|
||||
const req = a[a.length - 1];
|
||||
if (typeof req === 'function' && !req.__w) {
|
||||
const w = function (id) {
|
||||
try { window.__reqs.push(String(id)); } catch (e) {}
|
||||
const m = req.apply(this, arguments);
|
||||
try {
|
||||
if (m && typeof m.get === 'function' && !m.__g) {
|
||||
m.__g = true;
|
||||
const og = m.get;
|
||||
m.get = function () {
|
||||
let v;
|
||||
try { v = og.apply(this, arguments); } catch (e) { v = '__ERR__:' + e; }
|
||||
try { window.__mods.push([String(id), v]); } catch (e) {}
|
||||
return v;
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
return m;
|
||||
};
|
||||
w.__w = true;
|
||||
const na = Array.prototype.slice.call(a);
|
||||
na[na.length - 1] = w;
|
||||
return oa.call(this, t, na);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return oa.call(this, t, a);
|
||||
};
|
||||
})();`;
|
||||
|
||||
const SHOW = aid => `(async () => {
|
||||
const load = (src) => new Promise((res, rej) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src; s.onload = () => res(true); s.onerror = () => rej(new Error('fail'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
if (typeof window.TencentCaptcha !== 'function') await load('https://ssl.captcha.qq.com/TCaptcha.js');
|
||||
window.__capres = null;
|
||||
window.__cap = new window.TencentCaptcha('${aid}', function (r) { window.__capres = r; }, {});
|
||||
window.__cap.show();
|
||||
return {ok: true};
|
||||
})()`;
|
||||
|
||||
(async () => {
|
||||
const port = parseInt(arg('port', '9222'), 10);
|
||||
const aid = arg('aid', '195672344');
|
||||
const out = arg('out', '/tmp/1ms_bridge/modvals.json');
|
||||
const waitMs = parseInt(arg('wait', '9000'), 10);
|
||||
|
||||
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
||||
const page = list.find(t => t.type === 'page' && !(t.url || '').startsWith('chrome'));
|
||||
if (!page) throw new Error('没有可用 page target');
|
||||
const pageCdp = await Cdp.connect(page.webSocketDebuggerUrl);
|
||||
await pageCdp.send('Page.enable');
|
||||
await pageCdp.send('Runtime.enable');
|
||||
|
||||
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
|
||||
const brCdp = await Cdp.connect(ver.webSocketDebuggerUrl);
|
||||
|
||||
const sessions = [];
|
||||
brCdp.on(async m => {
|
||||
if (m.method === 'Target.attachedToTarget') {
|
||||
const { sessionId, targetInfo } = m.params;
|
||||
sessions.push({ sessionId, targetInfo });
|
||||
console.error(`[attach] ${targetInfo.type} ${(targetInfo.url || '').slice(0, 80)}`);
|
||||
try {
|
||||
await brCdp.send('Page.enable', {}, sessionId);
|
||||
await brCdp.send('Page.addScriptToEvaluateOnNewDocument', { source: HOOK }, sessionId);
|
||||
console.error(' -> hook registered');
|
||||
} catch (e) { console.error(' -> hook fail', e.message); }
|
||||
try { await brCdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId); }
|
||||
catch (e) {}
|
||||
}
|
||||
});
|
||||
await brCdp.send('Target.setAutoAttach', {
|
||||
autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
|
||||
});
|
||||
console.error('[1] autoAttach 已开(新 target 会先暂停,等我们注册钩子)');
|
||||
|
||||
console.error('[2] 导航到 1ms.run 并拉起验证码');
|
||||
await pageCdp.send('Page.navigate', { url: 'https://1ms.run/' });
|
||||
await sleep(4000);
|
||||
await pageCdp.send('Runtime.evaluate', {
|
||||
expression: SHOW(aid), awaitPromise: true, returnByValue: true,
|
||||
});
|
||||
await sleep(waitMs);
|
||||
|
||||
console.error('[3] 在各 session 里读取记账结果');
|
||||
const results = [];
|
||||
for (const s of sessions) {
|
||||
try {
|
||||
const r = await brCdp.send('Runtime.evaluate', {
|
||||
expression: `(() => { try { return {u: location.href, n: (window.__mods||[]).length,
|
||||
mods: window.__mods||[], reqs: window.__reqs||[]}; } catch(e) { return {err:String(e)}; } })()`,
|
||||
returnByValue: true,
|
||||
}, s.sessionId);
|
||||
const v = r.result && r.result.value;
|
||||
if (v && v.mods && v.mods.length) { results.push(v); }
|
||||
else if (v) console.error(` ${(v.u || '?').slice(0, 60)}: mods=${v.n || 0}`);
|
||||
} catch (e) { console.error(' read fail', e.message); }
|
||||
}
|
||||
|
||||
fs.writeFileSync(out, JSON.stringify(results, null, 1));
|
||||
console.error(`[4] -> ${out} (命中 ${results.length} 个 session 有记账)`);
|
||||
for (const r of results) {
|
||||
console.error(` ${(r.u || '').slice(0, 70)} mods=${r.mods.length} reqs=${r.reqs.length}`);
|
||||
}
|
||||
brCdp.close(); pageCdp.close();
|
||||
})().catch(e => { console.error('ERR', e.message); process.exit(1); });
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cdp_net_capture.js —— 用 CDP 抓某个标签页的网络请求(含 postData), 用于拿到
|
||||
* 前端**现场生成**的请求体 (如 1ms 的 /api/v1/captcha/params 里的 identity_context)。
|
||||
*
|
||||
* 零依赖: 只用 Node >= 22 内置 WebSocket / fetch。
|
||||
*
|
||||
* 用法:
|
||||
* node tools/cdp_net_capture.js --port 9222 --match 1ms.run --url-substr captcha/params --reload
|
||||
* node tools/cdp_net_capture.js --match 1ms.run --url-substr captcha/params --reload --wait 6000
|
||||
*/
|
||||
const fs = require('fs');
|
||||
|
||||
function arg(name, def) {
|
||||
const i = process.argv.indexOf('--' + name);
|
||||
if (i < 0) return def;
|
||||
const v = process.argv[i + 1];
|
||||
return (v === undefined || v.startsWith('--')) ? true : v;
|
||||
}
|
||||
|
||||
class Cdp {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.handlers = []; }
|
||||
static async connect(url) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
} else if (m.method) {
|
||||
for (const h of c.handlers) h(m);
|
||||
}
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}) {
|
||||
const id = ++this.id;
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise((res, rej) => this.waits.set(id, { res, rej }));
|
||||
}
|
||||
on(fn) { this.handlers.push(fn); }
|
||||
close() { try { this.ws.close(); } catch (e) {} }
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
(async () => {
|
||||
const port = parseInt(arg('port', '9222'), 10);
|
||||
const match = arg('match', null);
|
||||
const substr = arg('url-substr', null);
|
||||
const wait = parseInt(arg('wait', '8000'), 10);
|
||||
|
||||
const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json())
|
||||
.filter(t => t.type === 'page');
|
||||
let target = match ? targets.find(t => (t.url || '').includes(match)) : targets[0];
|
||||
if (!target) throw new Error('没有可用标签页');
|
||||
|
||||
const cdp = await Cdp.connect(target.webSocketDebuggerUrl);
|
||||
const reqs = [];
|
||||
cdp.on(m => {
|
||||
if (m.method === 'Network.requestWillBeSent') {
|
||||
const r = m.params.request;
|
||||
if (!substr || r.url.includes(substr)) {
|
||||
reqs.push({
|
||||
url: r.url, method: r.method, headers: r.headers,
|
||||
postData: r.postData || null, at: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
await cdp.send('Network.enable');
|
||||
await cdp.send('Page.enable');
|
||||
if (arg('reload', false)) {
|
||||
await cdp.send('Page.reload', { ignoreCache: true });
|
||||
}
|
||||
await sleep(wait);
|
||||
console.log(JSON.stringify({ target: target.url, captured: reqs }, null, 2));
|
||||
cdp.close();
|
||||
})().catch(e => { console.error('CDP_ERROR: ' + e.message); process.exit(1); });
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cdp_script_source.js —— 用 CDP Debugger 取某个标签页里**已解析脚本**的原文。
|
||||
*
|
||||
* 为什么需要它(而不是再 fetch 一次):
|
||||
* `tdc.js?app_data=..&t=..` **每次请求返回不同构建**, 所以"再抓一次"拿不到当前会话
|
||||
* 正在跑的那一份。只有 Debugger.scriptParsed + Debugger.getScriptSource 能拿到**运行时
|
||||
* 真正执行的那份源码**(缓存命中也不影响)。
|
||||
*
|
||||
* 零依赖 (Node >= 22 内置 WebSocket/fetch)。
|
||||
*
|
||||
* 用法:
|
||||
* node tools/cdp_script_source.js --match drag_ele --url-substr tdc.js --out /tmp/build.js
|
||||
* node tools/cdp_script_source.js --match drag_ele --list
|
||||
*/
|
||||
const fs = require('fs');
|
||||
|
||||
function arg(name, def) {
|
||||
const i = process.argv.indexOf('--' + name);
|
||||
if (i < 0) return def;
|
||||
const v = process.argv[i + 1];
|
||||
return (v === undefined || v.startsWith('--')) ? true : v;
|
||||
}
|
||||
|
||||
class Cdp {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.handlers = []; }
|
||||
static async connect(url) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
} else if (m.method) {
|
||||
for (const h of c.handlers) h(m);
|
||||
}
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}) {
|
||||
const id = ++this.id;
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise((res, rej) => this.waits.set(id, { res, rej }));
|
||||
}
|
||||
on(fn) { this.handlers.push(fn); }
|
||||
close() { try { this.ws.close(); } catch (e) {} }
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
(async () => {
|
||||
const port = parseInt(arg('port', '9222'), 10);
|
||||
const match = arg('match', null);
|
||||
const substr = arg('url-substr', null);
|
||||
const wait = parseInt(arg('wait', '1500'), 10);
|
||||
|
||||
const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json())
|
||||
.filter(t => t.type === 'page' || t.type === 'iframe');
|
||||
let target = match ? targets.find(t => (t.url || '').includes(match)) : targets[0];
|
||||
if (!target) throw new Error('没有可用标签页');
|
||||
|
||||
const cdp = await Cdp.connect(target.webSocketDebuggerUrl);
|
||||
const scripts = [];
|
||||
cdp.on(m => { if (m.method === 'Debugger.scriptParsed') scripts.push(m.params); });
|
||||
await cdp.send('Debugger.enable');
|
||||
await sleep(wait); // 等已经跑过的脚本被重新上报
|
||||
await cdp.send('Runtime.enable');
|
||||
await cdp.send('Runtime.evaluate', { expression: '1' }); // 触发一次上下文
|
||||
|
||||
if (arg('list', false)) {
|
||||
for (const s of scripts) console.log(`${String(s.url || '').slice(0, 120)}\t${s.length}B`);
|
||||
cdp.close();
|
||||
return;
|
||||
}
|
||||
const hits = scripts.filter(s => substr ? String(s.url || '').includes(substr) : true);
|
||||
if (!hits.length) throw new Error(`没找到匹配 ${substr} 的已解析脚本 (共 ${scripts.length} 个)`);
|
||||
const pick = hits[hits.length - 1];
|
||||
const r = await cdp.send('Debugger.getScriptSource', { scriptId: pick.scriptId });
|
||||
const out = arg('out', '/tmp/cdp_script.js');
|
||||
fs.writeFileSync(out, r.scriptSource, 'utf8');
|
||||
console.log(JSON.stringify({ ok: true, url: pick.url, length: r.scriptSource.length, out,
|
||||
candidates: hits.map(h => String(h.url).slice(0, 90)) }, null, 1));
|
||||
cdp.close();
|
||||
})().catch(e => { console.error('CDP_ERROR: ' + e.message); process.exit(1); });
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* cdp_shot.js —— 用 CDP 对**已经在跑的** Chrome 某个标签页截图存成 PNG。
|
||||
* 零依赖 (Node >= 22 内置 WebSocket/fetch)。
|
||||
*
|
||||
* 用法:
|
||||
* node tools/cdp_shot.js --match drag_ele --out /tmp/a.png # 整页
|
||||
* node tools/cdp_shot.js --match drag_ele --out /tmp/a.png --clip 10,68,340,243
|
||||
* node tools/cdp_shot.js --list
|
||||
*
|
||||
* 选项:
|
||||
* --port N 调试端口 (默认 9222)
|
||||
* --match SUBSTR 选 url 含该子串的 page/iframe 标签页 (默认第一个)
|
||||
* --out PATH 输出 PNG 路径 (默认 /tmp/cdp_shot.png)
|
||||
* --clip x,y,w,h 只截该区域 (CSS px, 相对该标签页视口)
|
||||
* --scale N 设备像素比 (默认 1)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
|
||||
function arg(name, def) {
|
||||
const i = process.argv.indexOf('--' + name);
|
||||
if (i < 0) return def;
|
||||
const v = process.argv[i + 1];
|
||||
return (v === undefined || v.startsWith('--')) ? true : v;
|
||||
}
|
||||
|
||||
class Cdp {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); }
|
||||
static async connect(url) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
}
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}) {
|
||||
const id = ++this.id;
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise((res, rej) => this.waits.set(id, { res, rej }));
|
||||
}
|
||||
close() { try { this.ws.close(); } catch (e) {} }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const port = parseInt(arg('port', '9222'), 10);
|
||||
if (arg('list', false)) {
|
||||
const t = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
|
||||
for (const x of t) console.log(`${x.type}\t${(x.url || '').slice(0, 120)}\t${x.id}`);
|
||||
return;
|
||||
}
|
||||
const match = arg('match', null);
|
||||
const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json())
|
||||
.filter(t => t.type === 'page' || t.type === 'iframe');
|
||||
let target = match ? targets.find(t => (t.url || '').includes(match)) : null;
|
||||
if (!target) target = targets[0];
|
||||
if (!target) throw new Error('没有可用标签页');
|
||||
|
||||
const cdp = await Cdp.connect(target.webSocketDebuggerUrl);
|
||||
const params = { format: 'png', captureBeyondViewport: !!arg('clip', false) };
|
||||
const clipRaw = arg('clip', null);
|
||||
if (clipRaw) {
|
||||
const [x, y, w, h] = String(clipRaw).split(',').map(Number);
|
||||
params.clip = { x, y, width: w, height: h, scale: parseFloat(arg('scale', '1')) };
|
||||
}
|
||||
const r = await cdp.send('Page.captureScreenshot', params);
|
||||
const out = arg('out', '/tmp/cdp_shot.png');
|
||||
fs.writeFileSync(out, Buffer.from(r.data, 'base64'));
|
||||
console.log(JSON.stringify({ ok: true, out, target: { url: target.url, id: target.id } }));
|
||||
cdp.close();
|
||||
})().catch(e => { console.error(String(e)); process.exit(1); });
|
||||
@@ -1,79 +0,0 @@
|
||||
// 在真 Chrome(9222) 里拉起 1ms 的腾讯验证码。
|
||||
// 用法: node tools/cdp_show_captcha.js
|
||||
function arg(name, def) {
|
||||
const i = process.argv.indexOf('--' + name);
|
||||
if (i < 0) return def;
|
||||
const v = process.argv[i + 1];
|
||||
return (v === undefined || v.startsWith('--')) ? true : v;
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
class Cdp {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.handlers = []; }
|
||||
static async connect(url) {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
const c = new Cdp(ws);
|
||||
ws.onmessage = ev => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.id && c.waits.has(m.id)) {
|
||||
const { res, rej } = c.waits.get(m.id);
|
||||
c.waits.delete(m.id);
|
||||
m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
|
||||
} else if (m.method) { for (const h of c.handlers) h(m); }
|
||||
};
|
||||
return c;
|
||||
}
|
||||
send(method, params = {}) {
|
||||
const id = ++this.id;
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise((res, rej) => this.waits.set(id, { res, rej }));
|
||||
}
|
||||
on(fn) { this.handlers.push(fn); }
|
||||
close() { try { this.ws.close(); } catch (e) {} }
|
||||
}
|
||||
|
||||
async function listTargets(port) {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/json/list`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const port = parseInt(arg('port', '9222'), 10);
|
||||
const aid = arg('aid', '195672344');
|
||||
const pageUrl = arg('url', 'https://1ms.run/');
|
||||
|
||||
const targets = await listTargets(port);
|
||||
let page = targets.find(t => t.type === 'page' && !(t.url || '').startsWith('chrome'));
|
||||
if (!page) throw new Error('没有可用页面 target');
|
||||
|
||||
const cdp = await Cdp.connect(page.webSocketDebuggerUrl);
|
||||
await cdp.send('Page.enable');
|
||||
await cdp.send('Runtime.enable');
|
||||
|
||||
console.log(`[1] 导航到 ${pageUrl}`);
|
||||
await cdp.send('Page.navigate', { url: pageUrl });
|
||||
await sleep(4000);
|
||||
|
||||
console.log(`[2] 注入 TCaptcha SDK 并 show(aid=${aid})`);
|
||||
const expr = `(async () => {
|
||||
const load = (src) => new Promise((res, rej) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = src; s.onload = () => res(true); s.onerror = () => rej(new Error('fail'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
if (typeof window.TencentCaptcha !== 'function') await load('https://ssl.captcha.qq.com/TCaptcha.js');
|
||||
window.__capres = null;
|
||||
window.__cap = new window.TencentCaptcha('${aid}', function (r) { window.__capres = r; }, {});
|
||||
window.__cap.show();
|
||||
return {ok: true};
|
||||
})()`;
|
||||
const r = await cdp.send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true });
|
||||
console.log(' ', JSON.stringify(r.result && r.result.value));
|
||||
|
||||
await sleep(2500);
|
||||
const t2 = await listTargets(port);
|
||||
console.log('[3] 当前 targets:');
|
||||
for (const t of t2) console.log(` ${t.type}\t${(t.url || '').slice(0, 100)}`);
|
||||
cdp.close();
|
||||
})().catch(e => { console.error('ERR', e.message); process.exit(1); });
|
||||
@@ -1,467 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
tdc_collectgen.py —— **按本次构建**生成 collect 明文(并加密)。
|
||||
|
||||
承 har/1ms_dianxuan/PROGRESS.md #29~#36:
|
||||
* #29/#31 槽位序**按构建随机化**,不能用缓存/旧模板;
|
||||
* #34/#35 槽位序 = 清单模块的 require 顺序,**可纯静态导出**;
|
||||
* #36 「清单项 → cd 槽」不是一一对应:`kind===2` 的项不进 cd,
|
||||
`kind===1` 的返回值是 JSON 碎片串、串内逗号会切出额外槽。
|
||||
|
||||
本模块做两件事:
|
||||
1. `reorder(template_cd, mapping, values)` —— 把"语义值"按**本次构建的槽位序**排成 cd;
|
||||
2. `build(...)` —— 组装成 `{"cd":[...],"sd":{...}}` 明文,并用该构建的 XTEA 密钥加密。
|
||||
|
||||
⚠️ 关于「语义值」从哪来(诚实说明,别把它当成已经解决的事):
|
||||
目前有两种取值来源,都可用,但**完备性不同**:
|
||||
* `--values <json>`:由调用方提供「语义名 -> 值」字典(最稳,推荐);
|
||||
* `--from-sample <cd json>`:用**另一份已解密的真机明文**做"同源重排"。
|
||||
这要求两份样本跑在**同一台机器/同一环境**(取值集合相同,只有顺序不同,见 #31),
|
||||
适用于"先拿一份真机明文当素材、再按本次构建重排"的验证场景。
|
||||
**仍需补齐**:纯算地把每个语义名对应的值算出来(多数是静态环境量,可配置;
|
||||
canvas/WebGL 指纹这类必须取自真实环境)。这一步未完成前,纯离线生成还不能保证过风控。
|
||||
|
||||
用法:
|
||||
# 用一份同机真机明文做重排, 按本次构建输出 collect
|
||||
.venv/bin/python tools/tdc_collectgen.py <tdc.js> \\
|
||||
--from-sample /tmp/real_cd.json --out /tmp/collect.b64
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
if str(ROOT / "tools") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
from tcaptcha.crypto import xtea_encrypt_bytes # noqa: E402
|
||||
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
|
||||
import tdc_layout # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 槽位映射 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def value_sig(v: Any) -> str:
|
||||
"""值的**结构签名**(用于在没有环境指纹时当次级锚点)。
|
||||
|
||||
只描述"形状 + 常量",抹掉随时间/会话变化的具体数值:
|
||||
1791736322 -> "int:epochish" [[34,96,3284],...] -> "list:pts"
|
||||
"MacIntel" -> "str:MacIntel" [360,360] -> "list:2"
|
||||
易变槽(时间戳/轨迹/随机串)的签名在两份样本里**类型一致**,数值不同 —— 正好
|
||||
用来把它们彼此对应起来,再由调用方决定"沿用样本值"还是"重新生成"。
|
||||
"""
|
||||
if v is None:
|
||||
return "null"
|
||||
if isinstance(v, bool):
|
||||
return f"bool:{v}"
|
||||
if isinstance(v, int):
|
||||
return "int:epochish" if 1_700_000_000 <= v <= 2_000_000_000 else f"int:{v}"
|
||||
if isinstance(v, float):
|
||||
return "float"
|
||||
if isinstance(v, str):
|
||||
if len(v) > 200:
|
||||
return "str:big"
|
||||
if _RANDSTR_RE.fullmatch(v):
|
||||
return "str:rand"
|
||||
return f"str:{v[:24]}"
|
||||
if isinstance(v, list):
|
||||
if v and all(isinstance(x, list) and len(x) == 3 for x in v):
|
||||
return "list:pts"
|
||||
return f"list:{len(v)}"
|
||||
return type(v).__name__
|
||||
|
||||
|
||||
_RANDSTR_RE = re.compile(r"[A-Za-z0-9+/=]{20,}")
|
||||
|
||||
|
||||
class _Unmapped:
|
||||
"""哨兵:标记"这个目标槽没找到源槽"。
|
||||
|
||||
为什么不能用 None:真机 cd 里本身就有 null 值(`out += "null"` 分支,
|
||||
见 PROGRESS.md #36),用 None 当占位符会把合法的 null 槽误判成"没对齐"。
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - 仅用于调试输出
|
||||
return "<unmapped>"
|
||||
|
||||
|
||||
_UNMAPPED = _Unmapped()
|
||||
|
||||
|
||||
def build_anchor_map(src: "SlotSpec", dst: "SlotSpec") -> List[Tuple[int, int]]:
|
||||
"""把「源构建的槽」对齐到「目标构建的槽」,返回 [(src_slot, dst_slot), ...]。
|
||||
|
||||
这是重排的核心。三层锚点,逐层降级(都必须**双向唯一**,否则宁可不对齐):
|
||||
|
||||
① **环境语义指纹**(主锚点,跨构建稳定,实测多重集交集 23/24)
|
||||
源项 i 与目标项 j 的 env_fingerprint 相同 -> 对齐。
|
||||
② **结构签名**(次锚点,用于常量槽 / 读不到环境的项)
|
||||
value_sig 相同 -> 对齐。
|
||||
③ 剩余未对齐的 -> 由调用方显式处理(不猜)。
|
||||
|
||||
为什么不能按项下标或模块 id 对齐(两个都已实测证伪):
|
||||
* 项下标:两次构建的清单顺序**就是**不同的东西,按下标对齐 = 恒等映射;
|
||||
* 模块 id:编译器每次重新分配,两份构建交集 28 个模块里 id->语义 相同的
|
||||
只有 1/11(2026-10-12 实测)。
|
||||
"""
|
||||
|
||||
def bipartite(keys_src: List[Any], keys_dst: List[Any]) -> Dict[int, int]:
|
||||
"""只在**双向唯一**时配对,避免多对一的歧义错位。"""
|
||||
from collections import defaultdict
|
||||
|
||||
by_dst: Dict[Any, List[int]] = defaultdict(list)
|
||||
by_src: Dict[Any, List[int]] = defaultdict(list)
|
||||
for i, k in enumerate(keys_src):
|
||||
if k:
|
||||
by_src[k].append(i)
|
||||
for j, k in enumerate(keys_dst):
|
||||
if k:
|
||||
by_dst[k].append(j)
|
||||
pair: Dict[int, int] = {}
|
||||
for k, srcs in by_src.items():
|
||||
dsts = by_dst.get(k) or []
|
||||
if len(srcs) == 1 and len(dsts) == 1:
|
||||
pair[srcs[0]] = dsts[0]
|
||||
return pair
|
||||
|
||||
pairs: Dict[int, int] = {}
|
||||
# 锚点优先级(2026-10-12 实测,4 份全新在线构建):
|
||||
# 锚点 跨构建唯一可配对
|
||||
# ① 语义名(案例§5.3 指纹) 35/37 <- 主锚点
|
||||
# ② 静态语义指纹 fp 29/37
|
||||
# ③ 指令直方图 hist 20/37
|
||||
# ④ 结构签名 value_sig (仅双方都有实测值时)
|
||||
# 所以:语义名先用,再逐级降级。每一级都要求**双向唯一**才配对。
|
||||
|
||||
# ① 语义名(案例 §5.3:字符串指纹 + 结构兜底 + 重名消歧)
|
||||
pairs.update(bipartite([n or "" for n in src.names],
|
||||
[n or "" for n in dst.names]))
|
||||
# ②③ 降级:静态语义指纹 -> 指令直方图
|
||||
for key in (lambda s: [f or "" for f in s.fps],
|
||||
lambda s: [h or "" for h in s.hists]):
|
||||
for i, j in bipartite(key(src), key(dst)).items():
|
||||
pairs.setdefault(i, j)
|
||||
# ④ 末级:结构签名(仅在双方都有实测值时可用)
|
||||
sig_pair = bipartite([value_sig(v) for v in src.sample_values],
|
||||
[value_sig(v) for v in dst.sample_values])
|
||||
for i, j in sig_pair.items():
|
||||
pairs.setdefault(i, j)
|
||||
|
||||
out: List[Tuple[int, int]] = []
|
||||
for i, (_i, _m, sslot, n) in enumerate(src.assign):
|
||||
j = pairs.get(i)
|
||||
if j is None or j >= len(dst.assign):
|
||||
continue
|
||||
_dj, _dm, dslot, dn = dst.assign[j]
|
||||
if dn != n:
|
||||
continue # 槽数形状不一致 -> 拒绝猜,跳过
|
||||
for k in range(n):
|
||||
out.append((sslot + k, dslot + k))
|
||||
return out
|
||||
|
||||
|
||||
def reorder(sample: Sequence[Any], dst: "SlotSpec",
|
||||
src: Optional["SlotSpec"] = None,
|
||||
fill: Optional[Dict[int, Any]] = None) -> List[Any]:
|
||||
"""把「源样本」的 cd 值重排成**目标构建**的槽位序。
|
||||
|
||||
⚠️ 旧实现的 bug(2026-10-12 修复):
|
||||
旧版签名是 `reorder(sample, spec)`,其中 `spec` 只是**目标构建**的规格,
|
||||
而循环里 `sample[slot:slot+n]` 用的 `slot` 恰恰是**目标构建自己的槽下标**。
|
||||
两次构建的槽位序不同(#31),所以这等价于"按目标下标切源数组" —— 顺序不同
|
||||
时全盘错位,顺序相同时才碰巧对。**必须同时给出源构建的规格**才能对齐。
|
||||
|
||||
参数:
|
||||
sample - 源构建产出的 cd 值数组(真机明文)
|
||||
dst - 目标构建的槽位规格(本次要发包的那个构建)
|
||||
src - 源构建的槽位规格。**不传则退化为旧行为**(仅当两份构建同序时才正确,
|
||||
此时本函数会先校验再放行,不再静默错位)。
|
||||
fill - {目标槽号: 值},用于补齐锚点匹配不上的槽。
|
||||
|
||||
锚点能力上限(2026-12 实测,见 tdc_layout.static_fingerprint / opcode_histogram):
|
||||
静态锚点组合 `fp+hist` 单构建内唯一 **29/37**,跨构建交集 35。
|
||||
剩下 8 项(分成 2 组)**静态不可区分**:它们的函数体只有一处 JUMP 目标不同,
|
||||
语义藏在跳转目标里,符号求值取不到。这些槽必须由调用方用 `fill` 显式给值。
|
||||
|
||||
未给 fill 且仍对不齐的槽 -> 抛 `UnmappedSlotsError`(宁可暴露"不知道",
|
||||
也不要静默填错值 —— 旧实现正是静默填错,导致多轮实验建立在坏数据上)。
|
||||
"""
|
||||
if src is None:
|
||||
# 契约:不提供源构建规格 = 调用方**承诺**样本与目标构建同序。
|
||||
# 此时重排只能是恒等映射;先把槽数校验掉,再直接返回副本。
|
||||
if len(sample) != dst.n_slots:
|
||||
raise ValueError(
|
||||
f"样本槽数 {len(sample)} != 目标构建槽数 {dst.n_slots};"
|
||||
f"未提供源构建规格时不跨构建重排(见 PROGRESS.md #31)")
|
||||
out: List[Any] = [_UNMAPPED] * dst.n_slots
|
||||
for d in range(dst.n_slots):
|
||||
out[d] = sample[d]
|
||||
for d, v in (fill or {}).items():
|
||||
if 0 <= d < dst.n_slots:
|
||||
out[d] = v
|
||||
return out
|
||||
|
||||
if len(sample) != src.n_slots:
|
||||
raise ValueError(
|
||||
f"样本槽数 {len(sample)} != 源构建槽数 {src.n_slots};"
|
||||
f"样本必须来自 `src` 描述的那个构建")
|
||||
|
||||
pairs = build_anchor_map(src, dst)
|
||||
# 用哨兵而不是 None 标记"未映射":真机 cd 里本身就有 null 值,
|
||||
# 拿 None 当占位符会把合法的 null 槽误判成"没对齐"。
|
||||
out: List[Any] = [_UNMAPPED] * dst.n_slots
|
||||
for s_slot, d_slot in pairs:
|
||||
out[d_slot] = sample[s_slot]
|
||||
unmapped = [d for d, v in enumerate(out) if v is _UNMAPPED]
|
||||
for d, v in (fill or {}).items():
|
||||
if 0 <= d < dst.n_slots:
|
||||
out[d] = v
|
||||
if d in unmapped:
|
||||
unmapped.remove(d)
|
||||
if unmapped:
|
||||
raise UnmappedSlotsError(unmapped, dst, src)
|
||||
return out
|
||||
|
||||
|
||||
class UnmappedSlotsError(RuntimeError):
|
||||
"""有目标槽找不到源槽与之对应(锚点匹配失败)。"""
|
||||
|
||||
def __init__(self, slots: List[int], dst: "SlotSpec", src: "SlotSpec") -> None:
|
||||
self.slots = slots
|
||||
super().__init__(
|
||||
f"目标构建有 {len(slots)} 个槽无法与源样本对齐(槽号 {slots[:12]}"
|
||||
f"{'...' if len(slots) > 12 else ''})。"
|
||||
f"锚点不足时宁可报错也不要静默填错值 —— 请用 `--values` 显式提供这些槽的取值,"
|
||||
f"或检查两份构建是否来自同一采集器集合。")
|
||||
|
||||
|
||||
def outer_commas(s: str) -> int:
|
||||
"""数**不在**任何 `[]` / `{}` 内的逗号个数(方括号内的逗号不构成顶层分隔)。"""
|
||||
depth = 0
|
||||
n = 0
|
||||
for ch in s:
|
||||
if ch in "[{":
|
||||
depth += 1
|
||||
elif ch in "]}":
|
||||
depth = max(0, depth - 1)
|
||||
elif ch == "," and depth == 0:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
class SlotSpec:
|
||||
"""一次构建下的槽位规格(规则见 PROGRESS.md #36)。"""
|
||||
|
||||
def __init__(self, seq: Sequence[int],
|
||||
kinds: Optional[Sequence[Any]] = None,
|
||||
v0s: Optional[Sequence[str]] = None,
|
||||
fps: Optional[Sequence[Optional[str]]] = None,
|
||||
hists: Optional[Sequence[str]] = None,
|
||||
names: Optional[Sequence[Optional[str]]] = None) -> None:
|
||||
self.seq = list(seq)
|
||||
self.kinds = list(kinds) if kinds is not None else [None] * len(seq)
|
||||
# v0s[i] = 该项 get()[0] 的字符串化结果;kind=1 时按它的逗号数切出额外槽
|
||||
self.v0s = list(v0s) if v0s is not None else [""] * len(seq)
|
||||
# fps[i] = 该项的**环境语义指纹**(跨构建稳定, 见 tdc_layout.env_fingerprint);
|
||||
# None/空串 表示该项读不到环境路径(常量槽 / 轨迹槽),需退到值签名或直接填值
|
||||
self.fps: List[Optional[str]] = list(fps) if fps is not None else [None] * len(seq)
|
||||
# hists[i] = 该项的**指令直方图**(兜底锚点,覆盖 37/37,见 tdc_layout.opcode_histogram)
|
||||
self.hists: List[str] = list(hists) if hists is not None else [""] * len(seq)
|
||||
# names[i] = 该项的**语义名**——案例 §5.3 的字符串+结构指纹(主锚点)。
|
||||
# 实测 4 份全新在线构建:37/37 识别、0 unknown、跨构建完全一致、**35/37 唯一可配对**。
|
||||
# 明显优于本项目早期自创的 fp/hist 锚点(29/37),所以放在最前面用。
|
||||
self.names: List[Optional[str]] = (
|
||||
list(names) if names is not None else [None] * len(seq))
|
||||
# 该构建下的一份**实测 cd 值**(源侧重排时要用它算结构签名)。
|
||||
# 目标侧可以留空;只有当成"源规格"参与 build_anchor_map 时才必须给。
|
||||
self.sample_values: List[Any] = []
|
||||
self.assign: List[Tuple[int, int, int, int]] = [] # (item, module, first_slot, n_slots)
|
||||
self._build()
|
||||
|
||||
def _build(self) -> None:
|
||||
slot = 0
|
||||
for i, m in enumerate(self.seq):
|
||||
k = self.kinds[i]
|
||||
n = 1
|
||||
if k == 1 and isinstance(self.v0s[i], str):
|
||||
# 原样拼 -> 该串会被 JSON 解析器按逗号切成多个值。
|
||||
# 但**数组/对象形态**的串('[]' / '[360,360]' / '["zh-CN","zh"]')里的逗号
|
||||
# 处在方括号内, 拼回去仍是一个值, 不产生新槽 —— 只数"括号外"的逗号。
|
||||
n += outer_commas(self.v0s[i])
|
||||
if k == 2:
|
||||
# kind=2 把 r3 清空转存 r4(不接在 cd 后面),但 r4 会在收尾 @4905
|
||||
# 被二次加工后重新拼进 r3 —— 实测它**仍然产出一个槽**。
|
||||
n = 1
|
||||
self.assign.append((i, m, slot, n))
|
||||
slot += n
|
||||
|
||||
@property
|
||||
def n_slots(self) -> int:
|
||||
return sum(a[3] for a in self.assign)
|
||||
|
||||
def describe(self) -> str:
|
||||
multi = [(i, m, n) for (i, m, _s, n) in self.assign if n > 1]
|
||||
return (f"项 {len(self.seq)} -> 槽 {self.n_slots} "
|
||||
f"(kind=2 跳过 {sum(1 for k in self.kinds if k == 2)} 项"
|
||||
+ (f"; 多槽项 {multi}" if multi else "") + ")")
|
||||
|
||||
|
||||
def load_shape(items_path: str, seq: Sequence[int]) -> Tuple[List[Any], List[str]]:
|
||||
"""从运行时取样(items json)里读出每项的 kind 与 get()[0] 的字符串化值。"""
|
||||
d = json.load(open(items_path, encoding="utf-8"))
|
||||
res = d.get("result", d)
|
||||
rows = res.get("out") or res.get("items") or []
|
||||
kinds: List[Any] = []
|
||||
v0s: List[str] = []
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
kinds.append(None); v0s.append(""); continue
|
||||
kinds.append(r.get("kind"))
|
||||
v = r.get("v0")
|
||||
v0s.append(str(v) if v is not None else "")
|
||||
if len(kinds) < len(seq):
|
||||
pad = len(seq) - len(kinds)
|
||||
kinds += [None] * pad
|
||||
v0s += [""] * pad
|
||||
return kinds[: len(seq)], v0s[: len(seq)]
|
||||
|
||||
|
||||
def load_sample_cd(path: str) -> List[Any]:
|
||||
raw = json.load(open(path, encoding="utf-8"))
|
||||
return raw["cd"] if isinstance(raw, dict) else raw
|
||||
|
||||
|
||||
def spec_from_js(path: str, kinds_path: Optional[str] = None,
|
||||
sample_cd: Optional[Sequence[Any]] = None) -> "SlotSpec":
|
||||
"""从一份 tdc.js 直接构造该构建的槽位规格(含环境语义指纹)。"""
|
||||
prog = tp.load(path)
|
||||
_mid, _entry, seq = tdc_layout.find_manifest_module(prog)
|
||||
kinds, v0s = load_shape(kinds_path, seq) if kinds_path else (None, None)
|
||||
fps = tdc_layout.item_fingerprints(prog, seq)
|
||||
hists = tdc_layout.item_histograms(prog, seq)
|
||||
# 主锚点:案例 §5.3 的字符串+结构指纹语义名(实测 35/37 唯一可配对)
|
||||
# 复用已加载的 prog(analyze_prog),避免把同一份 tdc.js 再解析一遍。
|
||||
# 不做静默降级(AGENTS.md 附录 A#8):锚点算不出来就抛错,
|
||||
# 让调用方明确知道"这次没有主锚点",而不是悄悄退化到较弱的锚点上。
|
||||
import tdc_modules
|
||||
names = [r["name"] for r in tdc_modules.analyze_prog(prog)]
|
||||
spec = SlotSpec(seq, kinds, v0s, fps, hists, names)
|
||||
if sample_cd is not None:
|
||||
spec.sample_values = list(sample_cd)
|
||||
return spec
|
||||
|
||||
|
||||
def spec_from_seq(seq: Sequence[int],
|
||||
kinds: Optional[Sequence[Any]] = None,
|
||||
names: Optional[Sequence[Optional[str]]] = None,
|
||||
sample_cd: Optional[Sequence[Any]] = None,
|
||||
v0s: Optional[Sequence[str]] = None) -> "SlotSpec":
|
||||
"""只用清单序列构造规格(**不需要 tdc.js**,供测试/合成用例使用)。
|
||||
|
||||
纯算重排只需要「每一项占几槽」+「每项的锚点名」;
|
||||
tdc.js 只用来**导出**这两样东西,本身不是重排的输入。
|
||||
"""
|
||||
spec = SlotSpec(seq, kinds, v0s, names=names)
|
||||
if sample_cd is not None:
|
||||
spec.sample_values = list(sample_cd)
|
||||
return spec
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 明文组装 + 加密 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_plain(cd: Sequence[Any], sd: Optional[dict] = None) -> str:
|
||||
sd = sd or {"od": "C", "ft": "qf_7Pf__H"}
|
||||
return json.dumps({"cd": list(cd), "sd": sd}, separators=(",", ":"))
|
||||
|
||||
|
||||
def encrypt_plain(plain: str, key: Sequence[int]) -> str:
|
||||
data = plain.encode("latin1")
|
||||
data += b"\x00" * ((8 - len(data) % 8) % 8) # 与真实样本一致: \x00 补齐
|
||||
return base64.b64encode(xtea_encrypt_bytes(data, list(key))).decode("ascii")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="按本次构建生成 collect")
|
||||
ap.add_argument("path", help="本次会话的 tdc.js(**目标构建**)")
|
||||
ap.add_argument("--src-build", default=None,
|
||||
help="源样本所属的那份 tdc.js。**跨构建重排必须给**;"
|
||||
"不给则假定源样本与目标构建同序(并会校验槽数)")
|
||||
ap.add_argument("--from-sample", default=None, help="一份已解密的 cd 数组(json)")
|
||||
ap.add_argument("--values", default=None, help="语义名->值的 json 字典(实验性)")
|
||||
ap.add_argument("--out", default=None, help="输出 collect(base64) 的文件")
|
||||
ap.add_argument("--plain-out", default=None, help="同时输出明文")
|
||||
ap.add_argument("--kinds", default=None,
|
||||
help="运行时取样的 items json(提供每项的 kind,见 #36)")
|
||||
args = ap.parse_args()
|
||||
|
||||
prog = tp.load(args.path)
|
||||
_mid, _entry, seq = tdc_layout.find_manifest_module(prog)
|
||||
key = extract_xtea_key_pure(open(args.path, encoding="utf-8", errors="replace").read())["key"]
|
||||
|
||||
kinds, v0s = load_shape(args.kinds, seq) if args.kinds else (None, None)
|
||||
fps = tdc_layout.item_fingerprints(prog, seq)
|
||||
hists = tdc_layout.item_histograms(prog, seq)
|
||||
names = [r["name"] for r in tdc_modules.analyze_prog(prog)]
|
||||
dst = SlotSpec(seq, kinds, v0s, fps, hists, names)
|
||||
anchored = sum(1 for f in fps if f)
|
||||
print(f"文件 : {args.path}")
|
||||
print(f"清单模块 : registry[{_mid}] 项数 {len(seq)}")
|
||||
print(f"槽位规格 : {dst.describe()}")
|
||||
print(f"锚点覆盖 : 语义名 {sum(1 for n in names if n and n != 'unknown')}/{len(seq)}"
|
||||
f" 静态指纹 {anchored}/{len(seq)}")
|
||||
print(f"XTEA 密钥 : {key}")
|
||||
|
||||
if args.from_sample:
|
||||
sample = load_sample_cd(args.from_sample)
|
||||
if args.src_build:
|
||||
src = spec_from_js(args.src_build, args.kinds, sample)
|
||||
else:
|
||||
src = None
|
||||
try:
|
||||
cd = reorder(sample, dst, src)
|
||||
except (ValueError, UnmappedSlotsError) as exc:
|
||||
print(f"❌ {exc}")
|
||||
return 1
|
||||
print(f"样本槽数 : {len(sample)} -> 重排后 {len(cd)}")
|
||||
if args.src_build:
|
||||
# 自证:锚点对齐后,值集合应当守恒(易变槽数值允许不同)
|
||||
print(f"对齐方式 : 环境语义指纹 + 结构签名(源={args.src_build})")
|
||||
elif args.values:
|
||||
vals = json.load(open(args.values, encoding="utf-8"))
|
||||
cd = []
|
||||
for (_i, m, _slot, n) in dst.assign:
|
||||
v = vals[str(m)]
|
||||
cd.extend(v[:n] if isinstance(v, list) else [v])
|
||||
print(f"按语义字典 : {len(cd)} 槽")
|
||||
else:
|
||||
print("需要 --from-sample 或 --values")
|
||||
return 1
|
||||
|
||||
plain = build_plain(cd)
|
||||
b64 = encrypt_plain(plain, key)
|
||||
print(f"明文长度 : {len(plain)}")
|
||||
print(f"collect : {len(b64)} 字符 (tlg={len(b64)})")
|
||||
if args.plain_out:
|
||||
Path(args.plain_out).write_text(plain, encoding="latin1")
|
||||
print(f"明文 -> {args.plain_out}")
|
||||
if args.out:
|
||||
Path(args.out).write_text(b64, encoding="ascii")
|
||||
print(f"collect -> {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,324 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
腾讯天御 TDC 混沌虚拟机 (Chaos VM) —— **纯 Python** 字节码提取 + 反汇编器
|
||||
|
||||
为什么需要它
|
||||
------------
|
||||
旧 `tools/tdc_vm_disasm.js` 依赖 Node + Babel,且用**线性扫描**解码。实测该字节码数组
|
||||
里混有数据区/不可达区,线性扫描一进入数据区就失步,产出 `UNKNOWN_7`(opcode 7 其实是空洞)、
|
||||
`UNKNOWN_5096` 这类把操作数当操作码的垃圾 —— 换句话说旧的"8151 条指令流"是坏的。
|
||||
|
||||
本工具:
|
||||
1. 纯 Python 从 tdc.js 文本里取出字节码数组 `z`(复刻其内联的 base64 解码 + 游程展开),
|
||||
不需要任何 JS 运行时;
|
||||
2. 用**可达性驱动的 CFG 反汇编**(从 @0 出发,沿 fall-through / JUMP / JUMP_IF_TRUE /
|
||||
PUSH_TRY 的 catch 目标推进),只在真正可执行的地址上解码 —— 数据区自然留空,
|
||||
不再失步。
|
||||
|
||||
opcode 表来自对 VM 源码 AST 的推导(`G = [ ... ]` 中第 i 个 handler 即 opcode i,
|
||||
操作数个数 = handler 体内 `z[H++]` 的出现次数),已固化在下方。
|
||||
|
||||
> ⚠️ **固化的表只对 `tcaptcha/core_js/tdc_sample.js` 有效。** 实测 **opcode 编号每份构建都整体重排**
|
||||
> (同一源码 pc0 的 `SET_STACK_LEN` 在 6 份构建里是 53/19/32/57/55/1)。
|
||||
> 处理其它构建请先用 `tcaptcha/tdc_opcodes.py: derive_opcode_table(js)` 推导该构建的表,再
|
||||
> `disassemble_cfg(z, names=…, arity=…, dynamic=…)` 注入。详见 VM_SPEC.md §3.1。
|
||||
|
||||
用法:
|
||||
.venv/bin/python tools/tdc_disasm.py [tdc.js] # 默认 tcaptcha/core_js/tdc_sample.js
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# opcode 表 (从 VM 源码 G 数组推导)
|
||||
# 名字: 语义 (部分沿用旧工具命名); arity: 固定操作数字节数
|
||||
# 57 = CREATE_CLOSURE: 动态 (3 + 2*capCount + paramCount)
|
||||
# --------------------------------------------------------------------------- #
|
||||
OP_NAMES: Dict[int, str] = {
|
||||
0: "PUSH_STR_CHAR", 1: "IS_EXCEPTION", 2: "DUP", 3: "XOR", 4: "EQ_STRICT",
|
||||
5: "MOD", 6: "CALL_FUNC", 8: "GET_GLOBAL_PROP", 9: "MAKE_PROP_REF",
|
||||
10: "TYPEOF", 11: "DELETE", 12: "LOGICAL_NOT", 13: "EQ_LOOSE", 14: "SET_PROP",
|
||||
15: "GREATER_THAN", 17: "SWAP", 18: "IN", 19: "RET_TRUE", 20: "GREATER_EQ",
|
||||
22: "PUSH_IMM", 23: "MUL", 25: "LOAD_REG_INDIRECT", 26: "ITER_NEXT",
|
||||
28: "BIND_THIS", 29: "PUSH_NULL", 30: "BITWISE_AND", 31: "PUSH_IMM_ARRAY",
|
||||
32: "MAKE_REG_PROP_REF", 33: "SUB", 34: "CALL_METHOD", 35: "SHR", 36: "SHL",
|
||||
37: "JUMP_IF_TRUE", 38: "FOR_IN_KEYS", 39: "ADD", 41: "NEW_METHOD",
|
||||
42: "PUSH_UNDEFINED", 45: "PUSH_FALSE", 46: "STORE_REG", 47: "ALLOC_REG",
|
||||
48: "POP_TRY", 49: "THROW", 51: "POP", 52: "DIV", 53: "SET_STACK_LEN",
|
||||
54: "REVERSE_TOP2", 55: "PUSH_EMPTY_STR", 56: "PUSH_TRY", 57: "CREATE_CLOSURE",
|
||||
59: "GET_PROP", 60: "JUMP", 61: "NEW_FUNC", 62: "BITWISE_OR", 64: "REPLACE_TOP_IMM",
|
||||
65: "SHR_UNSIGNED", 66: "PUSH_TRUE", 68: "LOAD_REG_IMM", 69: "CLEAR_EXCEPTION",
|
||||
}
|
||||
|
||||
# 固定操作数字节数 (未列出的 opcode = 0 字节)
|
||||
ARITY: Dict[int, int] = {
|
||||
0: 1, 6: 1, 17: 1, 22: 1, 31: 1, 34: 1, 37: 1, 41: 1, 47: 1, 53: 1,
|
||||
56: 2, 60: 1, 61: 1, 64: 1, 68: 1,
|
||||
}
|
||||
DYNAMIC_ARITY = {57} # 需要读操作数才能定长
|
||||
OP_JUMP = 60
|
||||
OP_JUMP_IF_TRUE = 37
|
||||
OP_PUSH_TRY = 56 # operands = (catchTarget, catchVarReg)
|
||||
OP_CREATE_CLOSURE = 57
|
||||
|
||||
_B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. 纯 Python 取出字节码数组 z (复刻 tdc.js 内联的 I() 解码 + 游程展开)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _b64_decode_custom(text: str) -> str:
|
||||
"""复刻 tdc.js 内联的 I(A): 标准 base64 码表, 忽略非码表字符 (含换行)。"""
|
||||
g = re.sub(r"[=]+$", "", str(text))
|
||||
idx = {ch: i for i, ch in enumerate(_B64_ALPHABET)}
|
||||
out: List[str] = []
|
||||
b = 0
|
||||
n = 0
|
||||
for ch in g:
|
||||
w = idx.get(ch, -1)
|
||||
if w == -1: # ~w falsy -> 跳过 (换行/空白)
|
||||
continue
|
||||
b = (64 * b + w) if (n % 4) else w
|
||||
old = n
|
||||
n += 1
|
||||
if old % 4: # 每组 4 个字符产出 3 字节
|
||||
out.append(chr(255 & (b >> ((-2 * n) & 6))))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _unescape_js_string(raw: str) -> str:
|
||||
"""还原 JS 字符串字面量里的转义 (重点: `\\n` -> 真换行)。
|
||||
|
||||
注意: `n` 本身是合法 base64 字符, 若把 `\\n` 当成字面 `n` 会**污染**解码结果,
|
||||
必须先把转义还原成真实字符 (非码表字符随后被解码器忽略)。
|
||||
"""
|
||||
out: List[str] = []
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
c = raw[i]
|
||||
if c == "\\" and i + 1 < len(raw):
|
||||
nxt = raw[i + 1]
|
||||
out.append({"n": "\n", "r": "\r", "t": "\t", "\\": "\\",
|
||||
'"': '"', "'": "'", "/": "/"}.get(nxt, nxt))
|
||||
i += 2
|
||||
continue
|
||||
out.append(c)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def extract_bytecode(js_text: str) -> List[int]:
|
||||
"""从 tdc.js 文本里取出混沌 VM 的字节码数组 z (纯 Python, 无 JS 运行时)。
|
||||
|
||||
结构 (实测): `__TENCENT_CHAOS_VM(0, function(A){...}(["<base64>",[<int>,...]]), ...)`
|
||||
这里以 `__TENCENT_CHAOS_VM` 为锚点定位 `}(["...` 实参, 不再用"最长字符串"猜测。
|
||||
"""
|
||||
anchor = js_text.rfind("__TENCENT_CHAOS_VM")
|
||||
if anchor < 0:
|
||||
raise ValueError("未找到 __TENCENT_CHAOS_VM 锚点")
|
||||
|
||||
# 找锚点之后的 `}([` -> 数组起始的 `[`
|
||||
k = js_text.find("}([", anchor)
|
||||
if k < 0:
|
||||
k = js_text.find("[", js_text.find("function", anchor))
|
||||
start = js_text.find("[", k)
|
||||
if start < 0 or start + 1 >= len(js_text) or js_text[start + 1] != '"':
|
||||
raise ValueError("未能定位字节码实参数组")
|
||||
|
||||
q1 = start + 1
|
||||
q2 = js_text.find('"', q1 + 1) # base64 字面量内不含 `"`
|
||||
if q2 < 0:
|
||||
raise ValueError("base64 字面量未闭合")
|
||||
b64 = _unescape_js_string(js_text[q1 + 1:q2])
|
||||
|
||||
# 取内层整数数组 (括号配对)
|
||||
i = q2 + 1
|
||||
while i < len(js_text) and js_text[i] in " \t\r\n,":
|
||||
i += 1
|
||||
if i >= len(js_text) or js_text[i] != "[":
|
||||
raise ValueError("base64 之后不是整数数组")
|
||||
depth = 0
|
||||
j = i
|
||||
while j < len(js_text):
|
||||
if js_text[j] == "[":
|
||||
depth += 1
|
||||
elif js_text[j] == "]":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
j += 1
|
||||
# 该整数数组会混用科学计数法 (如 `1e9` / `16e3`), 需按数值解析
|
||||
ints = [int(float(x)) for x in js_text[i + 1:j].split(",") if x.strip() != ""]
|
||||
|
||||
# 3) 复刻 VM 的游程展开
|
||||
chars = _b64_decode_custom(b64) # G
|
||||
w: List[int] = ints
|
||||
|
||||
q: List[int] = []
|
||||
h = 0
|
||||
m = w.pop(0) if w else -1
|
||||
g = w.pop(0) if w else 0
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal h, m, g
|
||||
while h == m:
|
||||
q.append(g)
|
||||
h += 1
|
||||
m = w.pop(0) if w else -1
|
||||
g = w.pop(0) if w else 0
|
||||
|
||||
for ch in chars:
|
||||
flush()
|
||||
q.append(ord(ch))
|
||||
h += 1
|
||||
flush()
|
||||
return q
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. 可达性驱动的 CFG 反汇编 (只在可执行地址解码)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _decode_one(z: List[int], pc: int, names: Optional[Dict[int, str]] = None,
|
||||
arity: Optional[Dict[int, int]] = None,
|
||||
dynamic: Optional[set] = None) -> Tuple[Optional[int], List[int], Optional[List[str]], int]:
|
||||
"""解码 pc 处一条指令 -> (opcode, operands, note, next_fallthrough_pc)。
|
||||
|
||||
返回值 (None,...) 表示该地址无法构成合法指令。
|
||||
|
||||
names/arity/dynamic 允许注入**按构建推导**的操作码表 (见 tcaptcha/tdc_opcodes.py);
|
||||
不传则用本模块固化的 core_js 表。
|
||||
"""
|
||||
if names is None:
|
||||
names = OP_NAMES
|
||||
if arity is None:
|
||||
arity = ARITY
|
||||
if dynamic is None:
|
||||
dynamic = DYNAMIC_ARITY
|
||||
if pc >= len(z):
|
||||
return None, [], None, pc
|
||||
op = z[pc]
|
||||
if op > 69 or op not in names: # 空洞 opcode / 越界 -> 非法
|
||||
return None, [], None, pc + 1
|
||||
pos = pc + 1
|
||||
operands: List[int] = []
|
||||
note: Optional[List[str]] = None
|
||||
|
||||
if op in dynamic:
|
||||
# 57: target, capCount, paramCount, [reg, reg]*capCount, [reg]*paramCount
|
||||
if pos + 3 > len(z):
|
||||
return None, [], None, pos
|
||||
target, cap, param = z[pos], z[pos + 1], z[pos + 2]
|
||||
pos += 3
|
||||
need = 2 * cap + param
|
||||
if pos + need > len(z):
|
||||
return None, [], None, pos
|
||||
operands = z[pos:pos + need]
|
||||
pos += need
|
||||
caps = [f"r{operands[2*i]}=r{operands[2*i+1]}" for i in range(cap)]
|
||||
params = [f"p{i}->r{operands[2*cap+i]}" for i in range(param)]
|
||||
note = [f"target=@{target}", f"caps=[{','.join(caps)}]", f"params=[{','.join(params)}]"]
|
||||
else:
|
||||
n = arity.get(op, 0)
|
||||
if pos + n > len(z):
|
||||
return None, [], None, pos
|
||||
operands = z[pos:pos + n]
|
||||
pos += n
|
||||
if names[op] in ("JUMP", "JUMP_IF_TRUE") and operands:
|
||||
note = [f"target=@{operands[0]}"]
|
||||
elif names[op] == "PUSH_TRY" and len(operands) == 2:
|
||||
note = [f"catchTarget=@{operands[0]}", f"catchVarReg=r{operands[1]}"]
|
||||
return op, operands, note, pos
|
||||
|
||||
|
||||
def disassemble_cfg(z: List[int], entry: int = 0, follow_closures: bool = True,
|
||||
names: Optional[Dict[int, str]] = None,
|
||||
arity: Optional[Dict[int, int]] = None,
|
||||
dynamic: Optional[set] = None) -> List[dict]:
|
||||
"""从 entry 出发按可达性反汇编; 返回按 pc 排序的指令列表。
|
||||
|
||||
names/arity/dynamic 可注入按构建推导的操作码表 (默认 = 本模块固化的 core_js 表)。
|
||||
"""
|
||||
if names is None:
|
||||
names = OP_NAMES
|
||||
if arity is None:
|
||||
arity = ARITY
|
||||
if dynamic is None:
|
||||
dynamic = DYNAMIC_ARITY
|
||||
# 控制流相关 opcode 按"语义名"定位, 不写死数值 (opcode 每份构建都重排)
|
||||
op_jump = next((o for o, n in names.items() if n == "JUMP"), OP_JUMP)
|
||||
op_jump_if = next((o for o, n in names.items() if n == "JUMP_IF_TRUE"), OP_JUMP_IF_TRUE)
|
||||
op_push_try = next((o for o, n in names.items() if n == "PUSH_TRY"), OP_PUSH_TRY)
|
||||
op_closure = next((o for o, n in names.items() if n == "CREATE_CLOSURE"), OP_CREATE_CLOSURE)
|
||||
# 终结符: 返回/抛出之后**没有 fall-through**。
|
||||
# 漏掉这一步会让控制流"穿过函数结尾"线性走下去, 把所有后续代码都算进当前子程序
|
||||
# (实测会把父函数的初始化序列误判成子函数自己的代码)。
|
||||
op_terminators = {o for o, n in names.items() if n in ("RET_TRUE", "THROW")}
|
||||
|
||||
visited: Dict[int, dict] = {}
|
||||
work = [entry]
|
||||
while work:
|
||||
pc = work.pop()
|
||||
if pc in visited or pc >= len(z) or pc < 0:
|
||||
continue
|
||||
op, operands, note, nxt = _decode_one(z, pc, names, arity, dynamic)
|
||||
if op is None:
|
||||
# 非法地址 (落在数据区): 记录但不继续
|
||||
visited[pc] = {"pc": pc, "op": None, "name": "DATA/INVALID", "operands": [], "note": None}
|
||||
continue
|
||||
visited[pc] = {"pc": pc, "op": op, "name": names[op], "operands": operands, "note": note}
|
||||
if op == op_jump:
|
||||
if operands:
|
||||
work.append(operands[0])
|
||||
continue # 无条件跳转: 无 fall-through
|
||||
if op == op_jump_if:
|
||||
if operands:
|
||||
work.append(operands[0])
|
||||
work.append(nxt) # 条件跳转: 有 fall-through
|
||||
continue
|
||||
if op == op_push_try and operands:
|
||||
work.append(operands[0]) # catch 目标可达
|
||||
if op == op_closure and follow_closures:
|
||||
# 操作数 [0] 是闭包体入口: 该段代码在闭包被调用时才执行, 但它是**代码**,
|
||||
# 必须作为遍历根加入, 否则大半个程序都算不到。
|
||||
work.append(z[pc + 1])
|
||||
if op in op_terminators: # RET_TRUE / THROW: 无 fall-through
|
||||
continue
|
||||
work.append(nxt)
|
||||
return [visited[k] for k in sorted(visited)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
def main() -> int:
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "tcaptcha/core_js/tdc_sample.js"
|
||||
js = open(path, encoding="utf-8", errors="replace").read()
|
||||
z = extract_bytecode(js)
|
||||
insts = disassemble_cfg(z)
|
||||
invalid = [i for i in insts if i["op"] is None]
|
||||
print(f"bytecode length : {len(z)}")
|
||||
print(f"reachable instrs: {len(insts)} (invalid/data slots: {len(invalid)})")
|
||||
op_used = sorted({i["op"] for i in insts if i["op"] is not None})
|
||||
holes = [o for o in op_used if o not in OP_NAMES]
|
||||
print(f"opcodes used : {op_used}")
|
||||
print(f"illegal opcodes : {holes}")
|
||||
out = path.rsplit(".", 1)[0] + ".disasm.txt"
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
for i in insts:
|
||||
if i["op"] is None:
|
||||
f.write(f"@{i['pc']:05d}: {i['name']}\n")
|
||||
else:
|
||||
extra = (" " + " ".join(i["note"])) if i["note"] else ""
|
||||
argc = (" " + str(i["operands"])) if (i["operands"] and not i["note"]) else ""
|
||||
f.write(f"@{i['pc']:05d}: {i['name']:<18}{extra}{argc}\n")
|
||||
print(f"written : {out}")
|
||||
for i in insts[:12]:
|
||||
print(" ", i)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,7 +4,7 @@
|
||||
腾讯天御 TDC 混沌 VM —— **纯 Python** XTEA 密钥提取 CLI (零 Node / 零 JS 运行时)。
|
||||
|
||||
用法:
|
||||
.venv/bin/python tools/tdc_key_pure.py [tdc.js] # 默认 core_js 样本
|
||||
.venv/bin/python tools/tdc_key_pure.py [tdc.js] # 默认内置样本
|
||||
.venv/bin/python tools/tdc_key_pure.py -v har/.../tdc.js # 打印推导过程
|
||||
.venv/bin/python tools/tdc_key_pure.py --opcodes tdc.js # 顺带打印按构建推导的 opcode 表
|
||||
|
||||
@@ -21,12 +21,12 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402
|
||||
from tcaptcha.telemetry.key import extract_xtea_key_pure # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="纯 Python 从 tdc.js 提取 XTEA 密钥")
|
||||
ap.add_argument("path", nargs="?", default="tcaptcha/core_js/tdc_sample.js")
|
||||
ap.add_argument("path", nargs="?", default="tcaptcha/tdc_sample.js")
|
||||
ap.add_argument("-v", "--verbose", action="store_true", help="打印密钥构造过程")
|
||||
ap.add_argument("--opcodes", action="store_true", help="打印按构建推导的 opcode 表")
|
||||
ap.add_argument("--json", action="store_true", help="以 JSON 输出")
|
||||
@@ -35,7 +35,7 @@ def main() -> int:
|
||||
js = open(args.path, encoding="utf-8", errors="replace").read()
|
||||
|
||||
if args.opcodes:
|
||||
from tcaptcha.tdc_opcodes import derive_opcode_table
|
||||
from tcaptcha.telemetry.opcodes import derive_opcode_table
|
||||
names, arity, dynamic, diag = derive_opcode_table(js)
|
||||
print("handlers=%d dynamic=%s" % (diag["handler_count"], sorted(dynamic)))
|
||||
for op in sorted(names):
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 用**真 VM** 当神谕: 定位「XTEA 密钥数组」在哪段字节码被造出来。
|
||||
*
|
||||
* 思路(绕开"用 Python 复刻 JS 运行时"的泥潭):
|
||||
* 给真 VM 的分发器打钩子, 每执行一条指令就扫一遍当前 E(寄存器区+栈),
|
||||
* 一旦出现「4 个元素、且每个元素 4 字节都是可打印 ASCII 的 32bit 数」
|
||||
* —— 那就是 XTEA 密钥数组(已确证形态) —— 立刻 dump 最近 N 条 PC。
|
||||
* 那段 PC 窗口就是密钥例程, 之后手工提升成 Python 即可。
|
||||
*
|
||||
* 用法: node tools/tdc_keytrace.js <tdc.js> [--window=400]
|
||||
*/
|
||||
const fs = require("fs");
|
||||
|
||||
const file = process.argv[2] || "tcaptcha/core_js/tdc_sample.js";
|
||||
const winArg = process.argv.find((a) => a.startsWith("--window="));
|
||||
const WINDOW = winArg ? parseInt(winArg.split("=")[1], 10) : 400;
|
||||
|
||||
let src = fs.readFileSync(file, "utf8");
|
||||
const needle = "M=G[z[H++]]()";
|
||||
if (!src.includes(needle)) { console.error("[!] 未找到分发器"); process.exit(2); }
|
||||
|
||||
globalThis.__PC = [];
|
||||
globalThis.__HITS = [];
|
||||
|
||||
// 目标: 密钥 4 个 ASCII 打包字对应的字符串 (由分析确定, 可用环境变量覆盖)
|
||||
const KEY_ASCII = process.env.KEY_ASCII || "ldFPRVkjJCKeigfH";
|
||||
const CHUNKS = [KEY_ASCII.slice(0, 4), KEY_ASCII.slice(4, 8), KEY_ASCII.slice(8, 12), KEY_ASCII.slice(12, 16)];
|
||||
function printablePacked(v) {
|
||||
if (!Number.isInteger(v)) return false;
|
||||
const b = [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
|
||||
return b.every((x) => x >= 32 && x < 127);
|
||||
}
|
||||
function isKeyArray(a) {
|
||||
if (!a || typeof a !== "object" || typeof a.length !== "number" || a.length !== 4) return false;
|
||||
for (let i = 0; i < 4; i++) if (!printablePacked(a[i])) return false;
|
||||
return true;
|
||||
}
|
||||
function asciiOf(a) {
|
||||
return a.map((x) => String.fromCharCode((x >>> 24) & 255, (x >>> 16) & 255, (x >>> 8) & 255, x & 255)).join("");
|
||||
}
|
||||
// 命中判定: 4-int 数组 / 含任意 key chunk 的字符串 / 恰好16字符的字符串
|
||||
function detect(v, depth, seen) {
|
||||
if (typeof v === "string") {
|
||||
if (v.indexOf(KEY_ASCII) >= 0) return "str-full:" + v;
|
||||
for (const c of CHUNKS) if (v.indexOf(c) >= 0) return "str-chunk:" + v;
|
||||
return null;
|
||||
}
|
||||
if (v && typeof v === "object") {
|
||||
if (depth > 3 || seen.has(v)) return null;
|
||||
seen.add(v);
|
||||
if (typeof v.length === "number" && isKeyArray(v)) return "arr4:" + asciiOf(v);
|
||||
// 数组元素
|
||||
if (typeof v.length === "number") {
|
||||
for (let i = 0; i < v.length; i++) { const d = detect(v[i], depth + 1, seen); if (d) return "[" + i + "]." + d; }
|
||||
}
|
||||
// 对象属性 (含字符串键)
|
||||
let ks = [];
|
||||
try { ks = Object.keys(v); } catch (e) { ks = []; }
|
||||
for (const k of ks) {
|
||||
let x; try { x = v[k]; } catch (e) { continue; }
|
||||
const d = detect(x, depth + 1, seen);
|
||||
if (d) return "." + k + ":" + d;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
globalThis.__SCAN = function (E) {
|
||||
try {
|
||||
for (let i = 0; i < E.length; i++) {
|
||||
const cell = E[i];
|
||||
if (!cell) continue;
|
||||
// 寄存器单元可能是 [值], 也可能本身就是那个值; 两种都扫
|
||||
const cands = [cell];
|
||||
if (Array.isArray(cell)) { cands.push(cell[0]); if (Array.isArray(cell[0])) cands.push(cell[0]); }
|
||||
let d = null, v = null;
|
||||
for (const c of cands) { d = detect(c, 0, new Set()); if (d) { v = c; break; } }
|
||||
if (d) {
|
||||
globalThis.__HITS.push({
|
||||
reg: i, how: d, value: typeof v === "string" ? v : (Array.isArray(v) ? Array.from(v) : String(v)),
|
||||
pc: globalThis.__PC[globalThis.__PC.length - 1],
|
||||
window: globalThis.__PC.slice(-WINDOW),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const TARGET_PC = parseInt(process.env.TARGET_PC || "9109", 10);
|
||||
globalThis.__XTEA = null;
|
||||
globalThis.__HOOK = function (H, E) {
|
||||
if (H === TARGET_PC && !globalThis.__XTEA) {
|
||||
const cell = E[7];
|
||||
globalThis.__XTEA = {
|
||||
at: H,
|
||||
r7_cell: (Array.isArray(cell) ? JSON.stringify(cell).slice(0, 400) : String(cell)),
|
||||
r7_val: (cell && typeof cell[0] === "object") ? JSON.stringify(cell[0]).slice(0, 400) : String(cell && cell[0]),
|
||||
prev_pcs: globalThis.__PC.slice(-300),
|
||||
};
|
||||
}
|
||||
};
|
||||
src = src.replace(needle, "M=(globalThis.__PC.push(H),globalThis.__HOOK(H,E),G[z[H++]]())");
|
||||
|
||||
globalThis.window = globalThis;
|
||||
globalThis.document = { cookie: "", referrer: "", createElement: () => ({ getContext: () => null, toDataURL: () => "", style: {} }), getElementById: () => null };
|
||||
globalThis.navigator = { userAgent: "Mozilla/5.0", platform: "MacIntel", languages: ["zh-CN"], language: "zh-CN", hardwareConcurrency: 10, cookieEnabled: true };
|
||||
globalThis.screen = { width: 2560, height: 1440, availWidth: 2560, availHeight: 1345, colorDepth: 24, pixelDepth: 24 };
|
||||
globalThis.location = { href: "https://turing.captcha.gtimg.com/1/template/drag_ele.html", hostname: "turing.captcha.gtimg.com", protocol: "https:" };
|
||||
globalThis.localStorage = { getItem: () => null, setItem: () => {} };
|
||||
globalThis.sessionStorage = { getItem: () => null, setItem: () => {} };
|
||||
globalThis.requestAnimationFrame = (f) => setTimeout(f, 0);
|
||||
globalThis.addEventListener = () => {}; globalThis.removeEventListener = () => {};
|
||||
globalThis.__TENCENT_CHAOS_STACK = {};
|
||||
|
||||
let err = null;
|
||||
try { eval(src); } catch (e) { err = String((e && e.stack) || e); }
|
||||
|
||||
// 顶层只"注册模块"; 真正算密钥要等宿主调用 -> 手动驱动
|
||||
const names = Object.keys(globalThis).filter((k) => /^(TDC|TCaptcha|__|tcaptcha)/i.test(k));
|
||||
process.stderr.write("[keytrace] globalThis 候选: " + JSON.stringify(names.slice(0, 20)) + "\n");
|
||||
try {
|
||||
if (globalThis.TDC && typeof globalThis.TDC.getData === "function") {
|
||||
process.stderr.write("[keytrace] 调用 TDC.getData(true) 驱动...\n");
|
||||
globalThis.__PC.push(-1);
|
||||
const c = globalThis.TDC.getData(true);
|
||||
globalThis.__COLLECT = c;
|
||||
process.stderr.write(`[keytrace] getData 返回长度=${c ? c.length : 0} 头部=${String(c).slice(0,90)}\n`);
|
||||
} else {
|
||||
process.stderr.write("[keytrace] 未找到 TDC.getData\n");
|
||||
}
|
||||
} catch (e) { process.stderr.write("[keytrace] 驱动异常: " + String(e).slice(0, 200) + "\n"); }
|
||||
|
||||
process.stderr.write(`[keytrace] 执行 PC=${globalThis.__PC.length} 命中=${globalThis.__HITS.length}${err ? " 顶层抛错: " + err.slice(0, 120) : ""}\n`);
|
||||
let out = globalThis.__HITS[0] || null;
|
||||
if (out) {
|
||||
process.stderr.write(`[keytrace] 命中寄存器 r${out.reg}, 密钥=${JSON.stringify(out.key)}, ASCII=${out.ascii}, 命中时 PC=@${out.pc}\n`);
|
||||
}
|
||||
const pcs=globalThis.__PC;
|
||||
const uniq=new Set(pcs);
|
||||
const inRange=[...uniq].filter(x=>x>=9000&&x<=9600);
|
||||
process.stdout.write(JSON.stringify({ key_hit: out, xtea: globalThis.__XTEA,
|
||||
pc_total: pcs.length, pc_unique: uniq.size, min: Math.min(...pcs), max: Math.max(...pcs),
|
||||
has_9109: uniq.has(9109), range9000_9600: inRange.sort((a,b)=>a-b).slice(0,40) }));
|
||||
@@ -1,345 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
tdc_layout.py —— 静态导出 `collect` 明文的 **cd 槽位表**(纯算, 不跑 JS, 不跑环境)。
|
||||
|
||||
背景(这条链是 2026-10-11 排查 errorCode=12 时打通的, 见 har/1ms_dianxuan/PROGRESS.md #31/#32):
|
||||
真机成功会话的 collect 明文解出来后, 发现 **5 份同日样本的 `cd` 槽位数(38)、取值集合、`sd`
|
||||
全都一样, 但槽位顺序 5/5 互不相同** —— 也就是"哪些字段排在哪一槽"是**按构建随机化**的。
|
||||
仓库里的 success_collect_template.b64 是**某一次**的顺序, 拿它发的 collect 会被服务端
|
||||
按"本次构建的槽位表"读成自相矛盾的环境 -> errorCode=12。
|
||||
|
||||
槽位序从哪来(程序结构, 见 tools/tdc_program.py):
|
||||
1. 顶层 @0 先把 62 个**模块工厂**按 id 填进一个数组 (registry[id] = function(module, exports, require));
|
||||
2. 其中一个模块(下称"清单模块")返回一个**数组**, 它的构造方式是一串
|
||||
r6[r6.length] = require(N)
|
||||
也就是 **`cd` 的槽位顺序 = 这串 require 的顺序**;
|
||||
3. 另一个模块负责把这些采集器按 `.on` / `.reset` / `.get` 分到三个数组, 再另有一个模块
|
||||
遍历"有 .get() 的那些", 依次取 `get()` 的值拼成 `{"cd":[...],"sd":{...}}`。
|
||||
|
||||
所以本工具做的事:
|
||||
* 找清单模块(= push(require(N)) 次数最多的那个模块);
|
||||
* 读出它的 require 序列 -> 槽位表 [(槽位号, 模块 id, 工厂 entry), ...];
|
||||
* 把每个槽位子模块(含其内部闭包)里的**环境属性字面量**列出来, 作为该槽的语义线索;
|
||||
* 可选: 读一份已解密的真机 collect 明文, 与槽位表并排打印, 人工/程序核对。
|
||||
|
||||
用法:
|
||||
# 只看槽位表
|
||||
.venv/bin/python tools/tdc_layout.py tcaptcha/core_js/tdc_sample.js
|
||||
|
||||
# 和真机明文并排核对 (明文是怎么来的: 同会话 tdc.js 密钥解密 verify 的 collect)
|
||||
.venv/bin/python tools/tdc_layout.py <tdc.js> --plain /tmp/real_collect_plain.txt
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
if str(ROOT / "tools") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
# 采集器用来读环境的属性名(用于给槽位打语义标签, 不是判定依据)
|
||||
ENV_KEYS = [
|
||||
"userAgent", "platform", "languages", "language", "hardwareConcurrency", "deviceMemory",
|
||||
"colorDepth", "pixelDepth", "availHeight", "availWidth", "deviceXDPI", "logicalXDPI",
|
||||
"fontSmoothingEnabled", "appVersion", "appName", "vendor", "cookieEnabled", "timezone",
|
||||
"getTimezoneOffset", "toDataURL", "getParameter", "getExtension", "TCaptchaReferrer",
|
||||
"webdriver", "referrer", "href", "width", "height", "innerWidth", "innerHeight",
|
||||
"devicePixelRatio", "characterSet", "MAX_TEXTURE_SIZE", "WEBGL_debug_renderer_info",
|
||||
]
|
||||
|
||||
|
||||
def _fold_str_before(code: List[dict], end: int, span: int = 40) -> Optional[str]:
|
||||
"""从 end 往前折叠一个字面串(PUSH_EMPTY_STR + 连续 PUSH_STR_CHAR)。"""
|
||||
chars: List[str] = []
|
||||
i = end
|
||||
while i >= 0 and i > end - span and code[i]["name"] == "PUSH_STR_CHAR":
|
||||
chars.append(chr(code[i]["operands"][0] & 0xFF))
|
||||
i -= 1
|
||||
if i >= 0 and code[i]["name"] == "PUSH_EMPTY_STR":
|
||||
return "".join(reversed(chars))
|
||||
return None
|
||||
|
||||
|
||||
def slot_value_expr(p: tp.Program, entry: int) -> Optional[str]:
|
||||
"""**精确**取一个槽位模块"这一槽的值是什么"。
|
||||
|
||||
规则(实测形状): 采集器模块最后做
|
||||
r3.exports.get = function () { ... return <被捕获的那个寄存器> ... }
|
||||
也就是 `get` 闭包**只返回它捕获的寄存器**。所以:
|
||||
1. 在工厂里找到被赋给属性 `get` 的那个 CREATE_CLOSURE;
|
||||
2. 从它的 caps 里取出**父寄存器号**;
|
||||
3. 该寄存器在工厂里的赋值表达式, 就是这一槽的语义。
|
||||
例: 模块 18 -> caps=[r3=r6], 工厂里 `r6 = c.navigator.userAgent`。
|
||||
"""
|
||||
code = p.function_code(entry)
|
||||
assigns = dict(p.symbolic_assignments(entry, limit=60))
|
||||
for k, inst in enumerate(code):
|
||||
if inst["name"] != "CREATE_CLOSURE":
|
||||
continue
|
||||
# 形状: <"get" 字面量>; REVERSE_TOP2; JUMP -> @site; @site: CREATE_CLOSURE
|
||||
j = k - 1
|
||||
if j >= 0 and code[j]["name"] == "JUMP":
|
||||
j -= 1
|
||||
if _fold_str_before(code, j) != "get":
|
||||
continue
|
||||
site = next((c for c in p.closures if c.pc == inst["pc"]), None)
|
||||
if site is None:
|
||||
continue
|
||||
for _child, parent in site.caps: # caps 是 (子寄存器, 父寄存器)
|
||||
expr = assigns.get(f"r{parent}")
|
||||
if expr:
|
||||
return expr
|
||||
# 兜底(覆盖更广): 采集器模块里通常**只有一个闭包**,它捕获的那个父寄存器就是取值寄存器
|
||||
for c in p.closures:
|
||||
if p.owner(c.pc) != entry or len(c.caps) != 1:
|
||||
continue
|
||||
_child, parent = c.caps[0]
|
||||
expr = assigns.get(f"r{parent}")
|
||||
if expr:
|
||||
return expr
|
||||
return None
|
||||
|
||||
|
||||
def slot_semantics(p: tp.Program, reg: Dict[int, int], mid: int, depth: int = 2) -> List[str]:
|
||||
"""还原某个槽位子模块"到底取什么值"。
|
||||
|
||||
三条线索合并(都来自 tdc_program 的符号求值, 不跑 JS):
|
||||
1. 模块工厂自身的赋值链, 例如 `r6 = c.navigator.userAgent`;
|
||||
2. **委派**: 有些模块只是 `r6 = r5(N)`(require 另一个模块再导出),要跟进去;
|
||||
3. **`get` 闭包内部**: canvas/WebGL 这类是延迟取值, 真正读环境在它内部创建的闭包里。
|
||||
"""
|
||||
exprs: List[str] = []
|
||||
seen: set = set()
|
||||
|
||||
def visit(m: int, d: int) -> None:
|
||||
if m in seen or d < 0 or m not in reg:
|
||||
return
|
||||
seen.add(m)
|
||||
entry = reg[m]
|
||||
for _r, v in p.symbolic_assignments(entry, limit=14):
|
||||
v = v.strip()
|
||||
if not v:
|
||||
continue
|
||||
exprs.append(v)
|
||||
mm = re.match(r"r\d+\((\d+)\)$", v) # 纯委派 r6 = r5(N)
|
||||
if mm:
|
||||
visit(int(mm.group(1)), d - 1)
|
||||
for c in p.closures: # 该模块内部创建的闭包(含 get 实现)
|
||||
if p.owner(c.pc) != entry:
|
||||
continue
|
||||
for _r, v in p.symbolic_assignments(c.target, limit=10):
|
||||
if v.strip():
|
||||
exprs.append(v.strip())
|
||||
mm = re.match(r"r\d+\((\d+)\)$", v.strip())
|
||||
if mm:
|
||||
visit(int(mm.group(1)), d - 1)
|
||||
|
||||
visit(mid, depth)
|
||||
uniq, out = set(), []
|
||||
for e in exprs:
|
||||
if e not in uniq:
|
||||
uniq.add(e)
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
_ENV_RE = re.compile(r"c\.|screen|navigator|document|window")
|
||||
_DELEG_RE = re.compile(r"r\d+\(\d*\)\s*$")
|
||||
|
||||
|
||||
def _is_delegation(e: str) -> bool:
|
||||
"""纯模块委派 `r5(22)` / `r5()` —— 括号里的模块号每份构建重排,不能进指纹。"""
|
||||
return bool(_DELEG_RE.fullmatch(e))
|
||||
|
||||
|
||||
def static_fingerprint(p: tp.Program, reg: Dict[int, int], mid: int,
|
||||
depth: int = 4, keep: int = 3) -> str:
|
||||
"""模块的**静态语义指纹** —— 跨构建对齐的主锚点(2026-10-12 实测)。
|
||||
|
||||
为什么不用模块 id(实测证伪):模块 id 是编译器序号,每份构建重新洗牌。
|
||||
两份构建清单交集的 28 个模块里 `id -> 语义` 完全相同的只有 **1/11**
|
||||
(模块9 在 A 是 canvas,在 B 是 webkitRTCPeerConnection)。按 id 对齐必错位。
|
||||
|
||||
构造规则(两条,都是为了抹掉"会重排"的部分、留下"不会变"的部分):
|
||||
1. 丢掉纯模块委派 `r5(22)` —— 括号里的模块号会重排;
|
||||
2. 其余符号表达式**排序**后取前 keep 条 —— 排序消除遍历顺序差异。
|
||||
|
||||
实测(har/1ms_dianxuan/tdc.js vs live/tdc_fresh.js):
|
||||
* 覆盖 29/37 项(比只取"环境路径"的 24/37 高不少 —— 常量槽、结构槽也有稳定形状)
|
||||
* 两份构建指纹**多重集交集 27/29**
|
||||
* 单构建内 **27/29 唯一**(只有 "Not supported | createElement(canvas)" 这一组重复)
|
||||
"""
|
||||
keepers: List[str] = []
|
||||
for e in slot_semantics(p, reg, mid, depth=depth):
|
||||
e = e.strip()
|
||||
if not e or _is_delegation(e):
|
||||
continue
|
||||
keepers.append(e)
|
||||
return " | ".join(sorted(set(keepers))[:keep])
|
||||
|
||||
|
||||
def env_fingerprint(p: tp.Program, reg: Dict[int, int], mid: int,
|
||||
depth: int = 4, keep: int = 3) -> str:
|
||||
"""只保留**读环境**的表达式(`c.`/screen/navigator/document/window)。
|
||||
|
||||
覆盖比 `static_fingerprint` 低(24/37 vs 29/37),仅在需要"这一槽读什么环境"
|
||||
这种**人类可读标签**时使用。做**对齐**请用 static_fingerprint。
|
||||
"""
|
||||
keepers: List[str] = []
|
||||
for e in slot_semantics(p, reg, mid, depth=depth):
|
||||
e = e.strip()
|
||||
if not e or _is_delegation(e) or not _ENV_RE.search(e):
|
||||
continue
|
||||
keepers.append(e)
|
||||
return " | ".join(sorted(set(keepers))[:keep])
|
||||
|
||||
|
||||
def opcode_histogram(p: tp.Program, reg: Dict[int, int], mid: int) -> str:
|
||||
"""模块的**指令直方图** —— 覆盖 37/37、跨构建多重集交集 37/37 的兜底锚点。
|
||||
|
||||
有些采集器(常量槽、纯闭包槽)的符号求值拿不到表达式(实测 8/37 项返回空串),
|
||||
但它们**函数体的 opcode 分布**是稳定的:编译器把同一份源码编成同一串指令,
|
||||
只是模块 id 换了。实测两份构建 37/37 全部匹配。
|
||||
|
||||
缺点:唯一性差一些(单构建内 20/37 唯一),所以只能当**第三级**锚点,
|
||||
在静态指纹匹配不上时兜底,并且同样要求"双向唯一"才配对。
|
||||
"""
|
||||
entry = reg.get(mid)
|
||||
if entry is None:
|
||||
return ""
|
||||
counts = Counter(inst["name"] for inst in p.function_code(entry))
|
||||
return "|".join(f"{k}:{v}" for k, v in sorted(counts.items()))
|
||||
|
||||
|
||||
def item_fingerprints(p: tp.Program, seq: Sequence[int]) -> List[str]:
|
||||
"""按清单顺序, 给每一项算出静态语义指纹(与 seq 等长)。"""
|
||||
reg = dict(p.module_registry(0))
|
||||
return [static_fingerprint(p, reg, m) for m in seq]
|
||||
|
||||
|
||||
def item_histograms(p: tp.Program, seq: Sequence[int]) -> List[str]:
|
||||
"""按清单顺序, 给每一项算出指令直方图(与 seq 等长, 兜底锚点)。"""
|
||||
reg = dict(p.module_registry(0))
|
||||
return [opcode_histogram(p, reg, m) for m in seq]
|
||||
|
||||
|
||||
def find_manifest_module(p: tp.Program) -> Tuple[int, int, List[int]]:
|
||||
"""找"清单模块": 体内 `push(require(N))` 次数最多者。
|
||||
|
||||
返回 (模块 id, 工厂 entry, require 序列)。
|
||||
"""
|
||||
reg = dict(p.module_registry(0))
|
||||
best = (0, -1, [])
|
||||
for mid, entry in reg.items():
|
||||
code = p.function_code(entry)
|
||||
seq: List[int] = []
|
||||
for k, inst in enumerate(code):
|
||||
if inst["name"] != "PUSH_IMM" or not inst["operands"]:
|
||||
continue
|
||||
if k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM" \
|
||||
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC":
|
||||
seq.append(int(inst["operands"][0]))
|
||||
if len(seq) > len(best[2]):
|
||||
best = (mid, entry, seq)
|
||||
return best
|
||||
|
||||
|
||||
def transitive_strings(p: tp.Program, entry: int, depth: int = 3) -> List[str]:
|
||||
"""模块自身 + 它内部创建的闭包(递归 depth 层)里的字面串。"""
|
||||
seen, out = set(), []
|
||||
frontier = {entry}
|
||||
for _ in range(depth):
|
||||
nxt = set()
|
||||
for e in frontier:
|
||||
if e in seen:
|
||||
continue
|
||||
seen.add(e)
|
||||
for pc, s in p.strings:
|
||||
if p.owner(pc) == e:
|
||||
out.append(s)
|
||||
for c in p.closures:
|
||||
if p.owner(c.pc) == e:
|
||||
nxt.add(c.target)
|
||||
frontier = nxt
|
||||
return out
|
||||
|
||||
|
||||
def env_hints(strings: List[str]) -> List[str]:
|
||||
hits = []
|
||||
for s in strings:
|
||||
for k in ENV_KEYS:
|
||||
if k.lower() in s.lower() and k not in hits:
|
||||
hits.append(k)
|
||||
return hits
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="静态导出 collect 的 cd 槽位表")
|
||||
ap.add_argument("path")
|
||||
ap.add_argument("--plain", default=None, help="已解密的真机 collect 明文 (json 文本)")
|
||||
ap.add_argument("--json", default=None, help="把槽位表写成 JSON")
|
||||
args = ap.parse_args()
|
||||
|
||||
p = tp.load(args.path)
|
||||
reg = dict(p.module_registry(0))
|
||||
mid, entry, seq = find_manifest_module(p)
|
||||
print(f"文件 : {args.path}")
|
||||
print(f"模块总数 : {len(reg)}")
|
||||
print(f"清单模块 : registry[{mid}] = @{entry}")
|
||||
print(f"槽位序列长度 : {len(seq)} (真机 cd 槽数 = 38, 差的部分是字面量槽)")
|
||||
print(f"require 序列 : {seq}")
|
||||
|
||||
plain_cd: Optional[list] = None
|
||||
if args.plain:
|
||||
txt = open(args.plain, encoding="latin1").read().rstrip("\x00").strip()
|
||||
obj = json.loads(txt)
|
||||
plain_cd = obj.get("cd")
|
||||
print(f"真机明文 cd 槽数: {len(plain_cd)}")
|
||||
|
||||
table = []
|
||||
print("\n槽位 模块id 工厂entry 取值语义(静态还原) 真机值")
|
||||
print("-" * 112)
|
||||
for i, m in enumerate(seq):
|
||||
tgt = reg.get(m)
|
||||
expr = slot_value_expr(p, tgt) if tgt else None
|
||||
# 有些模块只是"再 require 一层"(r6 = r5(M)),跟进一层
|
||||
mm = re.match(r"r\d+\((\d+)\)$", (expr or "").strip())
|
||||
if mm and int(mm.group(1)) in reg:
|
||||
sub = slot_value_expr(p, reg[int(mm.group(1))])
|
||||
if sub:
|
||||
expr = f"{sub} [via require({mm.group(1)})]"
|
||||
if not expr:
|
||||
expr = "; ".join(slot_semantics(p, reg, m)[:2]) if tgt else ""
|
||||
val = ""
|
||||
if plain_cd is not None and i < len(plain_cd):
|
||||
v = repr(plain_cd[i])
|
||||
val = v if len(v) <= 30 else v[:30] + "..."
|
||||
sem = expr or ""
|
||||
if len(sem) > 44:
|
||||
sem = sem[:44] + "..."
|
||||
table.append({"slot": i, "module": m, "entry": tgt, "expr": expr})
|
||||
print(f" {i:2d} {m:4d} @{tgt:<7} {sem:<46} {val}")
|
||||
|
||||
if plain_cd is not None:
|
||||
print(f"\n(真机 cd 里还有 {len(plain_cd) - len(seq)} 个槽不来自 require —— "
|
||||
f"通常是字面量, 例如 cd[0] 的空串)")
|
||||
if args.json:
|
||||
Path(args.json).write_text(json.dumps({"manifestModule": mid, "entry": entry,
|
||||
"slots": table}, ensure_ascii=False, indent=1),
|
||||
encoding="utf-8")
|
||||
print(f"槽位表 -> {args.json}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,329 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
腾讯天御 Chaos VM —— **字节码提升器 (lifter / devirtualizer)**
|
||||
|
||||
对应 AGENTS.md §3.3 步骤 5:「读指令流得到算法;子程序调用(闭包创建)递归展开」。
|
||||
|
||||
做什么
|
||||
------
|
||||
把栈式字节码**提升为可读伪代码**(不是模拟执行!):
|
||||
* 表达式栈还原: 每条指令的栈效应翻译成表达式文本
|
||||
* 寄存器/引用惯用法还原: `PUSH_IMM_ARRAY n`+`MAKE_REG_PROP_REF` -> `rN[key]`
|
||||
* 控制流: 基本块 + `label`/`goto`/`if (...) goto`
|
||||
* 闭包: `CREATE_CLOSURE` 递归展开成具名函数 (子程序)
|
||||
|
||||
为什么(对比"用 Python 复刻 JS 运行时")
|
||||
--------------------------------------
|
||||
* §2.3 铁律2: 复刻 JS 语义 = 打地鼠; 提升只是**读代码**, 不需要任何运行时
|
||||
* §2.1 最后一步: 读出算法后用目标语言原生重写, 跳过 VM/环境层
|
||||
* §4.2: 产出可查询资产 ("这个字段谁写的" = 看提升后的赋值语句即可)
|
||||
|
||||
用法
|
||||
----
|
||||
.venv/bin/python tools/tdc_lift.py tcaptcha/core_js/tdc_sample.js [--func @1518]
|
||||
输出 `<name>.lifted.txt`
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from tdc_disasm import OP_NAMES, DYNAMIC_ARITY, OP_CREATE_CLOSURE, OP_JUMP, OP_JUMP_IF_TRUE, OP_PUSH_TRY, extract_bytecode, disassemble_cfg # noqa: E402
|
||||
|
||||
# 二元运算 => 源码里写出的运算符
|
||||
BINOP = {
|
||||
3: "^", 4: "===", 5: "%", 13: "==", 15: ">", 18: "in", 20: ">=", 23: "*",
|
||||
30: "&", 33: "-", 35: ">>", 36: "<<", 39: "+", 52: "/", 62: "|", 65: ">>>",
|
||||
}
|
||||
CMP_PUSH = {19: "true", 29: "null", 42: "undefined", 45: "false", 55: '""', 66: "true"}
|
||||
|
||||
|
||||
def _q(s: str) -> str:
|
||||
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
class Expr:
|
||||
"""栈上的一个抽象值: 表达式文本 或 引用 (base, key)。"""
|
||||
|
||||
__slots__ = ("kind", "text", "base", "key")
|
||||
|
||||
def __init__(self, kind, text="", base=None, key=None):
|
||||
self.kind = kind # "v" = 值表达式, "ref" = 引用
|
||||
self.text = text
|
||||
self.base = base
|
||||
self.key = key
|
||||
|
||||
def __str__(self):
|
||||
if self.kind == "ref":
|
||||
return f"{self.base.text if isinstance(self.base, Expr) else self.base}" \
|
||||
f"[{self.key.text if isinstance(self.key, Expr) else self.key}]"
|
||||
return self.text
|
||||
|
||||
|
||||
def deref(e: Expr) -> str:
|
||||
return str(e)
|
||||
|
||||
|
||||
class Lifter:
|
||||
def __init__(self, z: List[int]):
|
||||
self.z = z
|
||||
self.funcs: Dict[int, dict] = {} # target pc -> {name}
|
||||
self.out: List[str] = []
|
||||
|
||||
# ------------------------------------------------------------------ 主流程
|
||||
def run(self, entry: int = 0, name: str = "vm_entry"):
|
||||
self.out.append(f"// ===== lifted from tdc.js bytecode ({len(self.z)} bytes) =====")
|
||||
self.funcs[entry] = {"name": name}
|
||||
self._lift_func(entry, name)
|
||||
# 递归提升所有被引用到的闭包 (子程序展开)
|
||||
done = {entry}
|
||||
while True:
|
||||
todo = [t for t in self.funcs if t not in done]
|
||||
if not todo:
|
||||
break
|
||||
for t in todo:
|
||||
done.add(t)
|
||||
self._lift_func(t, self.funcs[t]["name"])
|
||||
return "\n".join(self.out)
|
||||
|
||||
def _func_name(self, target: int) -> str:
|
||||
if target not in self.funcs:
|
||||
self.funcs[target] = {"name": f"sub_{target}"}
|
||||
return self.funcs[target]["name"]
|
||||
|
||||
def _lift_func(self, entry: int, name: str):
|
||||
insts = {i["pc"]: i for i in disassemble_cfg(self.z, entry, follow_closures=False) if i["pc"] >= entry}
|
||||
# ---------- 第一遍: 沿执行流传播"求值栈快照"(DFS, 每 pc 记录首次到达时的栈) ----------
|
||||
state: Dict[int, List[Expr]] = {entry: []}
|
||||
seen: Dict[int, bool] = {}
|
||||
work = [entry]
|
||||
while work:
|
||||
pc = work.pop()
|
||||
if pc in seen:
|
||||
continue
|
||||
st = list(state.get(pc, []))
|
||||
while pc in insts and pc not in seen:
|
||||
seen[pc] = True
|
||||
state.setdefault(pc, list(st)) # 每条 pc 都记录"进入时的栈"
|
||||
ins = insts[pc]
|
||||
self._emit(ins["op"], ins, pc, st, []) # 只改栈, 丢弃语句
|
||||
op = ins["op"]
|
||||
ops = ins["operands"] or []
|
||||
nxt = self._next_pc(ins)
|
||||
if op == OP_JUMP:
|
||||
succ = [ops[0]]
|
||||
elif op == OP_JUMP_IF_TRUE:
|
||||
succ = [ops[0], nxt]
|
||||
elif ins["name"] in ("RET_TRUE", "THROW"): # 按语义名, 不写死 opcode 数值
|
||||
succ = []
|
||||
else:
|
||||
succ = [nxt]
|
||||
for t in succ:
|
||||
if t in insts and t not in state:
|
||||
state[t] = list(st)
|
||||
if t in insts and t not in seen:
|
||||
work.append(t)
|
||||
if succ != [nxt]:
|
||||
break
|
||||
pc = nxt
|
||||
# ---------- 第二遍: 按地址序渲染 (块首用快照还原栈) ----------
|
||||
targets = {entry}
|
||||
for i in insts.values():
|
||||
for o in (i.get("operands") or []):
|
||||
if isinstance(o, int) and o in insts:
|
||||
targets.add(o)
|
||||
self.out.append("")
|
||||
self.out.append(f"function {name}() {{")
|
||||
pending: List[str] = []
|
||||
for pc in sorted(insts):
|
||||
ins = insts[pc]
|
||||
if ins["op"] is None:
|
||||
continue
|
||||
st = list(state.get(pc, []))
|
||||
if pc in targets:
|
||||
for x in pending:
|
||||
self.out.append(" " + x)
|
||||
pending.clear()
|
||||
self.out.append(f" @{pc:05d}:")
|
||||
self._emit(ins["op"], ins, pc, st, pending)
|
||||
for x in pending:
|
||||
self.out.append(" " + x)
|
||||
self.out.append("}")
|
||||
|
||||
def _next_pc(self, ins) -> int:
|
||||
from tdc_disasm import ARITY, DYNAMIC_ARITY
|
||||
op = ins["op"]; ops = ins["operands"] or []
|
||||
if op == OP_CREATE_CLOSURE:
|
||||
capn = self.z[ins["pc"] + 2]; parn = self.z[ins["pc"] + 3]
|
||||
return ins["pc"] + 1 + 3 + 2 * capn + parn
|
||||
return ins["pc"] + 1 + ARITY.get(op, 0)
|
||||
|
||||
def _fallthrough(self, ins) -> int:
|
||||
op = ins["op"]
|
||||
if op == OP_JUMP:
|
||||
return ins["operands"][0]
|
||||
if op == OP_JUMP_IF_TRUE:
|
||||
return ins["operands"][0]
|
||||
# 其它: 用下一块起点近似 (由 label_needed 决定是否打标签)
|
||||
return -1
|
||||
|
||||
# ------------------------------------------------------- 单条指令的栈效应
|
||||
def _emit(self, op: int, ins: dict, pc: int, st: List[Expr], out: List[str]):
|
||||
ops = ins["operands"]
|
||||
|
||||
def pop():
|
||||
return st.pop() if st else Expr("v", "<?>")
|
||||
|
||||
def push(t):
|
||||
st.append(Expr("v", t))
|
||||
|
||||
# --- 常量/字符串
|
||||
if op == 0: # PUSH_STR_CHAR
|
||||
c = chr(ops[0] & 0xFF)
|
||||
if st and st[-1].kind == "v" and st[-1].text.startswith('"'):
|
||||
st[-1].text = st[-1].text[:-1] + c.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
else:
|
||||
push(_q(c))
|
||||
return
|
||||
if op in CMP_PUSH:
|
||||
push(CMP_PUSH[op]); return
|
||||
if op == 22:
|
||||
push(str(ops[0])); return
|
||||
if op == 31: # PUSH_IMM_ARRAY -> [n] (寄存器引用惯用法)
|
||||
push(f"[{ops[0]}]"); return
|
||||
if op == 64:
|
||||
if st: st[-1] = Expr("v", str(ops[0]))
|
||||
else: push(str(ops[0]))
|
||||
return
|
||||
|
||||
# --- 寄存器
|
||||
if op == 68: # LOAD_REG_IMM r
|
||||
push(f"r{ops[0]}"); return
|
||||
if op == 25: # LOAD_REG_INDIRECT: E[key][0]
|
||||
idx = pop().text
|
||||
if idx.startswith("[") and idx.endswith("]"):
|
||||
idx = idx[1:-1]
|
||||
push(f"r{idx}"); return
|
||||
if op == 47: # ALLOC_REG r
|
||||
out.append(f"var r{ops[0]};"); return
|
||||
if op == 46: # STORE_REG: E[idx][0] = val
|
||||
val = pop(); ref = pop()
|
||||
out.append(f"r{ref.text.strip('[]')} = {val};"); return
|
||||
|
||||
# --- 引用/属性
|
||||
if op == 32: # MAKE_REG_PROP_REF: [rN[0], key]
|
||||
key = pop(); idx = pop()
|
||||
st.append(Expr("ref", base=Expr("v", f"r{idx.text.strip('[]')}"), key=key)); return
|
||||
if op == 9: # MAKE_PROP_REF: [base[key0], key]
|
||||
key = pop(); ref = pop()
|
||||
st.append(Expr("ref", base=Expr("v", str(ref)), key=key)); return
|
||||
if op == 59: # GET_PROP
|
||||
push(str(pop())); return
|
||||
if op == 8: # GET_GLOBAL_PROP -> c[key]
|
||||
push(f"c[{pop().text}]"); return
|
||||
if op == 14: # SET_PROP
|
||||
val = pop(); ref = pop()
|
||||
out.append(f"{ref} = {val};"); return
|
||||
if op == 10:
|
||||
push(f"typeof {pop().text}"); return
|
||||
if op == 11:
|
||||
push(f"delete {pop().text}"); return
|
||||
if op == 12:
|
||||
push(f"!{pop().text}"); return
|
||||
if op == 38:
|
||||
push(f"Object.keys({pop().text})"); return
|
||||
|
||||
# --- 算术/比较
|
||||
if op in BINOP:
|
||||
b = pop(); a = pop()
|
||||
push(f"({a.text} {BINOP[op]} {b.text})"); return
|
||||
|
||||
# --- 栈操作
|
||||
if op == 2:
|
||||
st.append(Expr("v", st[-1].text) if st else Expr("v", "<?>")); return
|
||||
if op == 51:
|
||||
pop(); return
|
||||
if op == 54: # REVERSE_TOP2 -> [a, b].reverse()
|
||||
b = pop(); a = pop(); push(f"[{b.text}, {a.text}]"); return
|
||||
if op == 17: # SWAP n
|
||||
n = ops[0]
|
||||
if len(st) >= 2 + n:
|
||||
i = len(st) - 2 - n
|
||||
st[i], st[-1] = st[-1], st[i]
|
||||
return
|
||||
if op in (53, 42, 69): # SET_STACK_LEN / PUSH_UNDEFINED / CLEAR_EXCEPTION
|
||||
return
|
||||
if op == 1:
|
||||
out.append("// (异常检查: 结束)"); return
|
||||
|
||||
# --- 调用
|
||||
if op in (6, 34, 61, 41):
|
||||
n = ops[0]
|
||||
args = [pop().text for _ in range(n)][::-1]
|
||||
if op == 34:
|
||||
ref = pop()
|
||||
push(f"{ref}({', '.join(args)})")
|
||||
else:
|
||||
fn = pop()
|
||||
push(f"{fn.text}({', '.join(args)})")
|
||||
return
|
||||
|
||||
# --- 闭包
|
||||
if op == OP_CREATE_CLOSURE:
|
||||
target = self.z[pc + 1]
|
||||
capn = self.z[pc + 2]
|
||||
parn = self.z[pc + 3]
|
||||
pos = pc + 4
|
||||
caps = []
|
||||
for _ in range(capn):
|
||||
caps.append(f"r{self.z[pos]}<-r{self.z[pos+1]}"); pos += 2
|
||||
params = [f"p{i}->r{self.z[pos+i]}" for i in range(parn)]
|
||||
nm = self._func_name(target)
|
||||
out.append(f"// closure {nm}({', '.join(params)}) caps=[{', '.join(caps)}] target=@{target}")
|
||||
push(nm)
|
||||
return
|
||||
|
||||
# --- 控制流
|
||||
if op == OP_JUMP:
|
||||
out.append(f"goto @{ops[0]};"); return
|
||||
if op == OP_JUMP_IF_TRUE:
|
||||
c = pop()
|
||||
out.append(f"if ({c.text}) goto @{ops[0]};"); return
|
||||
if op == OP_PUSH_TRY:
|
||||
out.append(f"try {{}} catch -> @{ops[0]} (var r{ops[1]})"); return
|
||||
if op == 48:
|
||||
out.append("// pop try"); return
|
||||
if op == 49:
|
||||
out.append(f"throw {pop().text};"); return
|
||||
if op == 26:
|
||||
push(f"next({pop().text})"); return
|
||||
if op == 28:
|
||||
v = pop(); push("[c, " + v.text + "]"); return
|
||||
# 其它未覆盖: 记录原名, 不破坏栈
|
||||
out.append(f"// [unhandled {OP_NAMES.get(op, op)} {ops}]")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "tcaptcha/core_js/tdc_sample.js"
|
||||
func = None
|
||||
if "--func" in sys.argv:
|
||||
func = int(sys.argv[sys.argv.index("--func") + 1].lstrip("@"))
|
||||
js = open(path, encoding="utf-8", errors="replace").read()
|
||||
z = extract_bytecode(js)
|
||||
lf = Lifter(z)
|
||||
text = lf.run(func if func is not None else 0, "vm_entry" if func is None else f"func_{func}")
|
||||
out = path.rsplit(".", 1)[0] + ".lifted.txt"
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write(text + "\n")
|
||||
print(f"bytecode={len(z)} lifted functions={len(lf.funcs)} -> {out}")
|
||||
print("--- 头 40 行 ---")
|
||||
print("\n".join(text.splitlines()[:40]))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,458 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
tdc_modules.py —— **按案例 §5.3 的字符串指纹识别 37 个采集器模块**。
|
||||
|
||||
> "从 v1 的全部错位到指纹方案的全部正确,关键不是更复杂的分析,
|
||||
> 而是换了一个更好的问题 —— 不再问『这个模块返回什么值』,
|
||||
> 而是问『这个模块的代码长什么样』。" —— docs/tdc-案例.md §5.3
|
||||
|
||||
为什么必须做这件事(2026-10-12 实测,推翻项目早期假设):
|
||||
* **模块 id 每份构建重新分配**。两份构建清单交集的 28 个模块里,
|
||||
`id -> 语义` 完全相同的只有 **1/11**(模块 8 在 A 是 `navigator.platform`,
|
||||
在 B 是 canvas 指纹)。按 id 对齐必然错位。
|
||||
* 但**模块代码里的字符串集合跨构建完全不变**:采集 userAgent 的模块
|
||||
一定含 `'userAgent'`;采集时区的一定含 `'getTimezoneOffset'`。这就是不变量。
|
||||
|
||||
本模块做三件事:
|
||||
1. `module_strings()` —— 取一个模块的**可达**字符串集合(含内部闭包,递归委派)
|
||||
2. `resolve_delegate()` —— 解析**委派链**(见下)
|
||||
3. `identify()` —— 用指纹规则表把模块判成语义名
|
||||
|
||||
委派链(本项目新发现,案例未提但实测必需):
|
||||
清单里有 6 个模块**自身不采集任何东西**,身体只是 `exports.get = require(N)`:
|
||||
项8 模块21 -> require(22)
|
||||
项9 模块24 -> require(25)
|
||||
项27 模块49 -> require(31)
|
||||
项31 模块53 -> require(0)
|
||||
它们的字符串集合只有 {'Array','Object','exports','get'} —— **零区分性**,
|
||||
必须顺着委派链跟到真正的采集器才能识别。这 6 个就是案例 §5.2 说的
|
||||
"6 个无 .get 模块"的来源之一。
|
||||
|
||||
用法:
|
||||
from tdc_modules import analyze
|
||||
info = analyze("har/1ms_dianxuan/tdc.js")
|
||||
for row in info:
|
||||
print(row["item"], row["module"], row["name"], row["strings"][:4])
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
if str(ROOT / "tools") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import tdc_layout # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 通用字符串(每个模块都有,无区分性,进指纹前必须剔除) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
NOISE: Set[str] = {
|
||||
"Array", "Object", "exports", "get", "Math", "RegExp", "String", "Number",
|
||||
"JSON", "set", "reset", "on", "length", "slice", "prototype", "constructor",
|
||||
"push", "apply", "call", "bind", "toString", "valueOf", "hasOwnProperty",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 指纹规则表(案例 §5.3)—— 每个语义名对应一组「必须出现的字符串」 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 形式: 语义名 -> (必需串集合, 权重提示)
|
||||
# 判定: 模块的区分串集合 **包含** 某个规则的必需串 -> 命中该规则
|
||||
# 形式: (语义名, 必需串, 禁用串)
|
||||
# 判定: required ⊆ strings 且 forbidden ∩ strings == ∅
|
||||
#
|
||||
# ⚠️ 规则必须**按 specificity 从高到低排**(必需串多的在前)。
|
||||
# 早期版本只按列表顺序取第一个命中,结果 webdriver_detect 被误判成 user_agent
|
||||
# —— 两者都只要求 1 个串,而 user_agent 恰好排在前面。
|
||||
# 给特征明显的规则补上更多必需串(如 webdriver 要求 2 个自动化标记),
|
||||
# 它们就会天然压过 user_agent 这类单串规则。
|
||||
#
|
||||
# fp_salt 之所以需要 forbidden: 它和 iframe_detect 的串集合几乎相同
|
||||
# (都是 appendChild/body/createElement/document),唯一区别是 iframe_detect
|
||||
# 多一个 contentWindow。用禁用串把两者分开。
|
||||
FINGERPRINTS: List[Tuple[str, Set[str], Set[str]]] = [
|
||||
# ---- 复合指纹(多必需串,天然压过单串规则)----
|
||||
("screen_fingerprint", {"screen", "width", "height", "availHeight", "colorDepth"}, set()),
|
||||
("webrtc_ip", {"RTCPeerConnection", "createDataChannel", "createOffer"}, set()),
|
||||
("webgl_renderer", {"UNMASKED_RENDERER_WEBGL", "WEBGL_debug_renderer_info"}, set()),
|
||||
("webgl_vendor", {"UNMASKED_VENDOR_WEBGL", "WEBGL_debug_renderer_info"}, set()),
|
||||
("webdriver_detect", {"$cdc_asdjflasutopfhvcZLmcfl_", "__webdriver_script_fn"}, set()),
|
||||
("canvas_fingerprint", {"ClientJS,org <canvas> 1.0", "#069"}, set()),
|
||||
("dom_compliance", {"::marker", "CSS"}, set()),
|
||||
("template_url", {"?rand=*", "URL"}, set()),
|
||||
("charset", {"characterSet", "charset"}, set()),
|
||||
("viewport_size", {"clientWidth", "clientHeight"}, set()),
|
||||
("time_since_load", {"getTime", "load"}, set()),
|
||||
# 轨迹采集器:真机实测其字符串集是鼠标/触摸事件名(2026-10-12 修正)。
|
||||
# 旧规则写成 {"stringify"} —— 那抓到的是另一个只做 JSON.stringify 的模块,
|
||||
# 结果「空数组」被记成 trajectory,真正的轨迹却在 window_time 名下。
|
||||
("trajectory", {"mouseMoveEvent", "touchStartEvent"}, set()),
|
||||
("struct_76_33", {"stringify"}, set()),
|
||||
# ---- 单串规则(放最后,避免压过上面的复合规则)----
|
||||
("user_agent", {"userAgent"}, set()),
|
||||
("platform", {"platform"}, set()),
|
||||
("languages", {"languages"}, set()),
|
||||
("hardware_concurrency", {"hardwareConcurrency"}, set()),
|
||||
("cookie_enabled", {"cookieEnabled"}, set()),
|
||||
("timezone_offset", {"getTimezoneOffset"}, set()),
|
||||
("itoken", {"TDC_itoken"}, set()),
|
||||
("guid_token", {r"^\d+:\d+$"}, set()),
|
||||
("referrer", {"TCaptchaReferrer"}, set()),
|
||||
("location_href", {"href"}, {"?rand=*", "TCaptchaReferrer"}),
|
||||
("iframe_detect", {"contentWindow"}, set()),
|
||||
("console_detect", {"console"}, set()),
|
||||
("touch_detect", {"ontouchstart"}, set()),
|
||||
("window_time", {"getTime"}, set()),
|
||||
("constant_98k", {"98k"}, set()),
|
||||
("screen_width", {"screen", "width"}, set()),
|
||||
("screen_height", {"screen", "height"}, set()),
|
||||
("screen_color_depth", {"colorDepth"}, set()),
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 结构兜底规则(案例 §5.3: "对于没有区分性字符串的通用模块,
|
||||
# 用 .get 函数的指令数 + entry 函数的指令数 + 特定指令来区分")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 形式: (语义名, 判定函数) —— 只在**字符串规则全落空**时启用。
|
||||
#
|
||||
# ⚠️ 命名纪律:下面这些名字**只描述「观测到的结构分组」,不猜测业务语义**。
|
||||
# 早期版本把 entry48/get9/call1 那组臆断成 "itoken_relay" —— 那是没有证据的猜测。
|
||||
# 现在统一用 "struct_<entry>_<get>[_call]" 这种**纯结构标签**:
|
||||
# 它诚实地表达「我们只知道它长这样,还不知道它采集什么」,
|
||||
# 同时又因为跨构建稳定,仍然可以当**对齐锚点**用。
|
||||
#
|
||||
# 实测(4 份全新在线构建 har/online/20261012-01*,2026-10-12):
|
||||
# entry135/get121/… -> 每份构建恰好 1 个 => fp_salt(随机串每次都变,只能靠结构)
|
||||
# entry46/get9/… -> 每份构建恰好 1 个 => 稳定分组
|
||||
# entry48/get9/call1 -> 每份构建恰好 1 个 => 稳定分组
|
||||
# entry40/get9/… -> 每份构建 **2 个** => 组内无法再分,需运行时取样才能定
|
||||
STRUCT_RULES = [
|
||||
("fp_salt", lambda s: s["entry_instr"] >= 120 and s["get_instr"] >= 100),
|
||||
("struct_46_9", lambda s: s["entry_instr"] == 46 and s["get_instr"] == 9),
|
||||
("struct_48_9_call", lambda s: s["entry_instr"] == 48 and s["get_instr"] == 9
|
||||
and s["call_func"]),
|
||||
("struct_40_9", lambda s: s["entry_instr"] == 40 and s["get_instr"] == 9),
|
||||
]
|
||||
|
||||
|
||||
def struct_signature(p: tp.Program, reg: Dict[int, int], mid: int) -> Dict:
|
||||
"""取一个模块的**结构签名**(案例 §5.3 兜底用)。
|
||||
|
||||
entry_instr = 模块工厂函数体的指令数
|
||||
get_instr = `.get` 闭包(JUMP 目标)函数体的指令数
|
||||
"""
|
||||
entry = reg.get(mid)
|
||||
if entry is None:
|
||||
return {"entry_instr": -1, "get_instr": -1, "call_func": False,
|
||||
"closure_kind": ""}
|
||||
code = p.function_code(entry)
|
||||
ops = Counter(inst["name"] for inst in code)
|
||||
get_n = -1
|
||||
for inst in code:
|
||||
if inst["name"] == "JUMP" and inst["operands"]:
|
||||
try:
|
||||
get_n = len(p.function_code(inst["operands"][0]))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
return {
|
||||
"entry_instr": len(code),
|
||||
"get_instr": get_n,
|
||||
"call_func": ops.get("CALL_FUNC", 0) > 0,
|
||||
"closure_kind": _closure_kind(p, entry),
|
||||
}
|
||||
|
||||
|
||||
def _closure_kind(p: tp.Program, entry: int) -> str:
|
||||
"""模块唯一闭包**最后写入什么** —— 用于拆开结构完全相同的模块。
|
||||
|
||||
背景(2026-10-12,4 份全新在线构建):
|
||||
有 2 个模块 entry 都是 40 条、.get 都是 9 条、指令逐条相同,
|
||||
只差一处 JUMP 绝对地址(每构建不同,不能当特征)。
|
||||
真正的区别在**闭包体**(CREATE_CLOSURE 的 target,不是 JUMP 目标):
|
||||
|
||||
A: new Array()[0] = "" (PUSH_EMPTY_STR; SET_PROP)
|
||||
B: new Array()[0] = 1558325408 (PUSH_IMM <随机int32>; SET_PROP)
|
||||
|
||||
实测每份构建恰好 empty×1 + rand×1 —— 稳定、可静态消歧。
|
||||
|
||||
返回: "empty" | "rand" | "const" | ""(无闭包)
|
||||
"""
|
||||
cls = [c for c in p.closures if p.owner(c.pc) == entry]
|
||||
if not cls:
|
||||
return ""
|
||||
try:
|
||||
body = p.function_code(cls[0].target)
|
||||
except Exception:
|
||||
return ""
|
||||
imms = [inst["operands"][0] for inst in body
|
||||
if inst["name"] == "PUSH_IMM" and inst["operands"]]
|
||||
if any(v > 1_000_000 for v in imms):
|
||||
return "rand"
|
||||
if any(inst["name"] == "PUSH_EMPTY_STR" for inst in body):
|
||||
return "empty"
|
||||
return "const" if imms else ""
|
||||
|
||||
|
||||
def _identify_struct(p: tp.Program, reg: Dict[int, int], mid: int) -> Optional[str]:
|
||||
"""字符串规则落空时的兜底识别。"""
|
||||
sig = struct_signature(p, reg, mid)
|
||||
for name, pred in STRUCT_RULES:
|
||||
try:
|
||||
if pred(sig):
|
||||
return name
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _clean(s: str) -> str:
|
||||
return s.strip()
|
||||
|
||||
|
||||
def module_strings(p: tp.Program, reg: Dict[int, int], mid: int,
|
||||
depth: int = 3) -> Set[str]:
|
||||
"""取一个模块的**可达**字符串集合(模块自身 + 内部闭包,递归 depth 层)。"""
|
||||
entry = reg.get(mid)
|
||||
if entry is None:
|
||||
return set()
|
||||
by_owner: Dict[int, Set[str]] = defaultdict(set)
|
||||
for pc, s in p.strings:
|
||||
by_owner[p.owner(pc)].add(s)
|
||||
|
||||
seen: Set[int] = set()
|
||||
frontier = {entry}
|
||||
out: Set[str] = set()
|
||||
for _ in range(depth):
|
||||
nxt: Set[int] = set()
|
||||
for e in frontier:
|
||||
if e in seen:
|
||||
continue
|
||||
seen.add(e)
|
||||
out |= by_owner.get(e, set())
|
||||
for c in p.closures:
|
||||
if p.owner(c.pc) == e and c.target not in seen:
|
||||
nxt.add(c.target)
|
||||
frontier = nxt
|
||||
if not frontier:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def resolve_delegate(p: tp.Program, reg: Dict[int, int], mid: int,
|
||||
max_hops: int = 6) -> List[int]:
|
||||
"""解析模块 m 的委派链: `exports.X = require(N)` 里的 N。
|
||||
|
||||
返回 [m, N1, N2, ...](含自身)。取不到委派时返回 [m]。
|
||||
|
||||
形状(实测): 模块体里出现 `LOAD_REG_IMM; PUSH_IMM N; CALL_FUNC` 且 N 在
|
||||
registry 里 -> 该模块委派给 N。有些模块 require 的是自己,要防环。
|
||||
"""
|
||||
chain: List[int] = [mid]
|
||||
seen = {mid}
|
||||
cur = mid
|
||||
for _ in range(max_hops):
|
||||
entry = reg.get(cur)
|
||||
if entry is None:
|
||||
break
|
||||
code = p.function_code(entry)
|
||||
nxt: Optional[int] = None
|
||||
for k, inst in enumerate(code):
|
||||
if inst["name"] != "PUSH_IMM" or not inst["operands"]:
|
||||
continue
|
||||
if k >= 1 and code[k - 1]["name"] == "LOAD_REG_IMM" \
|
||||
and k + 1 < len(code) and code[k + 1]["name"] == "CALL_FUNC":
|
||||
cand = int(inst["operands"][0])
|
||||
if cand in reg and cand not in seen:
|
||||
nxt = cand
|
||||
break
|
||||
if nxt is None:
|
||||
break
|
||||
chain.append(nxt)
|
||||
seen.add(nxt)
|
||||
cur = nxt
|
||||
return chain
|
||||
|
||||
|
||||
def _has(strings: Set[str], token: str) -> bool:
|
||||
"""判断 token 是否出现在字符串集合里。
|
||||
|
||||
两种模式:
|
||||
* 普通 token -> **精确**相等
|
||||
* `前缀*` 结尾 -> **子串/前缀**匹配
|
||||
|
||||
为什么需要后者(踩过的坑): `?rand=` 在真机里是 `?rand=1519713624347`
|
||||
(带会话随机数),精确匹配永远命中不了,导致 template_url 被误判成
|
||||
location_href。凡是"带运行时随机后缀"的串都必须用前缀匹配。
|
||||
"""
|
||||
if token in strings:
|
||||
return True
|
||||
if token.endswith("*"):
|
||||
stem = token[:-1]
|
||||
return any(stem in s for s in strings)
|
||||
return False
|
||||
|
||||
|
||||
def _matches(strings: Set[str]) -> List[Tuple[str, int, Set[str]]]:
|
||||
"""列出所有命中的规则,按 specificity(必需串个数)降序。"""
|
||||
hits: List[Tuple[str, int, Set[str]]] = []
|
||||
for name, required, forbidden in FINGERPRINTS:
|
||||
if not all(_has(strings, t) for t in required):
|
||||
continue
|
||||
if any(_has(strings, t) for t in forbidden):
|
||||
continue
|
||||
hits.append((name, len(required), required))
|
||||
hits.sort(key=lambda x: -x[1])
|
||||
return hits
|
||||
|
||||
|
||||
def _identify(strings: Set[str]) -> Tuple[str, Set[str]]:
|
||||
"""用指纹规则判定语义名。返回 (名字, 命中的必需串)。
|
||||
|
||||
取**必需串最多**的那条规则(specificity 优先),而不是列表里第一条 ——
|
||||
这样 `webdriver_detect`(2 个必需串)不会被 `user_agent`(1 个)抢走。
|
||||
"""
|
||||
hits = _matches(strings)
|
||||
if not hits:
|
||||
return "unknown", set()
|
||||
return hits[0][0], hits[0][2]
|
||||
|
||||
|
||||
def disambiguate(rows: List[Dict]) -> None:
|
||||
"""给**重名**的项加稳定后缀,使 37 个锚点尽可能唯一(原地修改 name)。
|
||||
|
||||
为什么需要这一步(2026-10-12 实测,只看 har/online/ 全新样本):
|
||||
字符串规则会把不同采集器判成同一个名字。跨 4 份新构建统计,重名是**稳定的**:
|
||||
|
||||
user_agent x2 -> entry48(委派链) / entry68(直判) 每份各 1 个 ✓ 可消歧
|
||||
window_time x3 -> entry279 / entry40 / entry48(委派链) 每份各 1 个 ✓ 可消歧
|
||||
struct_40_9 x2 -> 结构完全相同 每份各 2 个 ✗ 静态不可分
|
||||
|
||||
消歧后缀用**结构签名**(entry/get 指令数 + 是否含 CALL_FUNC),因为它是
|
||||
跨构建稳定的实测分组依据,而不是猜的语义。
|
||||
|
||||
⚠️ struct_40_9 那组每份构建固定 2 个、结构完全一致 —— 这是**静态识别的能力上限**。
|
||||
要靠运行时取样(TDC.getData 对照)才能分开。本函数会如实保留重名,
|
||||
并在 row["ambiguous_static"] 上标记,调用方据此决定是否需要运行时标定。
|
||||
"""
|
||||
groups: Dict[str, List[Dict]] = defaultdict(list)
|
||||
for r in rows:
|
||||
groups[r["name"]].append(r)
|
||||
|
||||
for name, members in groups.items():
|
||||
if len(members) <= 1:
|
||||
continue
|
||||
for r in members:
|
||||
s = r.get("struct") or {}
|
||||
tag = "e%s_g%s%s%s" % (s.get("entry_instr", "-"),
|
||||
s.get("get_instr", "-"),
|
||||
"_call" if s.get("call_func") else "",
|
||||
# 结构完全相同时,用"闭包最后写入什么"再拆一层
|
||||
("_" + s["closure_kind"]) if s.get("closure_kind") else "")
|
||||
# 同组内的 tag 是否唯一?唯一才能消歧
|
||||
r["_tag"] = tag
|
||||
tags = [r["_tag"] for r in members]
|
||||
can_split = len(set(tags)) == len(tags)
|
||||
for r in members:
|
||||
r["ambiguous_static"] = not can_split
|
||||
r["name_unique"] = "%s#%s" % (name, r["_tag"]) if can_split else name
|
||||
if can_split:
|
||||
for r in members:
|
||||
r["name"] = r["name_unique"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 主入口 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def analyze_prog(p: tp.Program, depth: int = 3) -> List[Dict]:
|
||||
"""分析一个**已加载**的 Program(供调用方复用,避免重复解析 tdc.js)。"""
|
||||
reg = dict(p.module_registry(0))
|
||||
_mid, _entry, seq = tdc_layout.find_manifest_module(p)
|
||||
|
||||
rows: List[Dict] = []
|
||||
for i, m in enumerate(seq):
|
||||
own = module_strings(p, reg, m, depth=depth)
|
||||
chain = resolve_delegate(p, reg, m)
|
||||
# 顺着委派链往下找,直到能识别出语义(或链走完)
|
||||
name, hit = _identify(own - NOISE)
|
||||
used_chain = False
|
||||
if name == "unknown" and len(chain) > 1:
|
||||
for deeper in chain[1:]:
|
||||
s = module_strings(p, reg, deeper, depth=depth)
|
||||
n2, h2 = _identify(s - NOISE)
|
||||
if n2 != "unknown":
|
||||
name, hit = n2, h2
|
||||
used_chain = True
|
||||
break
|
||||
# 字符串规则全落空 -> 启用案例 §5.3 的结构兜底(按指令数识别)
|
||||
via_struct = False
|
||||
if name == "unknown":
|
||||
n3 = _identify_struct(p, reg, m)
|
||||
if n3:
|
||||
name, hit = n3, set()
|
||||
via_struct = True
|
||||
sig = sorted((own - NOISE))
|
||||
rows.append({
|
||||
"item": i,
|
||||
"module": m,
|
||||
"name": name,
|
||||
"hit": sorted(hit),
|
||||
"delegate": chain if len(chain) > 1 else [],
|
||||
"via_delegate": used_chain,
|
||||
"via_struct": via_struct,
|
||||
"struct": struct_signature(p, reg, m),
|
||||
"ambiguous_static": False,
|
||||
"strings": sig,
|
||||
})
|
||||
disambiguate(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def analyze(path: str, depth: int = 3) -> List[Dict]:
|
||||
"""分析一份 tdc.js,返回清单每一项的识别结果(按清单顺序)。"""
|
||||
return analyze_prog(tp.load(path), depth=depth)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
import json
|
||||
|
||||
ap = argparse.ArgumentParser(description="按字符串指纹识别 37 个采集器模块")
|
||||
ap.add_argument("path")
|
||||
ap.add_argument("--json", default=None, help="把结果写成 JSON")
|
||||
ap.add_argument("--only-unknown", action="store_true", help="只列出未识别的")
|
||||
args = ap.parse_args()
|
||||
|
||||
rows = analyze(args.path)
|
||||
unknown = [r for r in rows if r["name"] == "unknown"]
|
||||
print("文件 : %s" % args.path)
|
||||
print("清单项 : %d 已识别 %d 未识别 %d"
|
||||
% (len(rows), len(rows) - len(unknown), len(unknown)))
|
||||
print()
|
||||
print("项 模块 语义名 委派链 区分串")
|
||||
print("-" * 108)
|
||||
for r in rows:
|
||||
if args.only_unknown and r["name"] != "unknown":
|
||||
continue
|
||||
dele = "->".join(str(x) for x in r["delegate"]) if r["delegate"] else ""
|
||||
mark = " *" if r["via_delegate"] else " "
|
||||
print("%2d %-5d %s%-21s %-17s %s"
|
||||
% (r["item"], r["module"], mark, r["name"], dele,
|
||||
r["strings"][:5]))
|
||||
if args.json:
|
||||
Path(args.json).write_text(
|
||||
json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
print("\n结果 -> %s" % args.json)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,389 +0,0 @@
|
||||
/**
|
||||
* Tencent Turing Captcha TDC Deep Proxy Runtime Probe
|
||||
* ---------------------------------------------------
|
||||
* 依据 AGENTS.md §4.3 与 §2.3 规范,通过深度 Proxy 探针拦截并记录 TDC 执行时
|
||||
* 实际读取的全部环境属性清单与调用时序,彻底告别盲目补环境。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const vm = require("vm");
|
||||
|
||||
function createDeepProxy(target, prefix, logSet) {
|
||||
return new Proxy(target, {
|
||||
get(obj, prop, receiver) {
|
||||
if (typeof prop === "symbol" || prop === "inspect" || prop.startsWith("_")) {
|
||||
return Reflect.get(obj, prop, receiver);
|
||||
}
|
||||
const BUILTIN_TYPES = new Set([
|
||||
"String", "Number", "Boolean", "Array", "Object", "Function",
|
||||
"RegExp", "Date", "Math", "JSON", "Error", "TypeError",
|
||||
"Uint8Array", "Uint8ClampedArray", "parseInt", "parseFloat",
|
||||
"encodeURIComponent", "decodeURIComponent", "setTimeout",
|
||||
"clearTimeout", "setInterval", "clearInterval"
|
||||
]);
|
||||
if (BUILTIN_TYPES.has(String(prop))) {
|
||||
return Reflect.get(obj, prop, receiver);
|
||||
}
|
||||
const fullName = `${prefix}.${String(prop)}`;
|
||||
logSet.add(`GET: ${fullName}`);
|
||||
|
||||
const val = Reflect.get(obj, prop, receiver);
|
||||
if (val !== null && typeof val === "object" && !(val instanceof RegExp)) {
|
||||
return createDeepProxy(val, fullName, logSet);
|
||||
}
|
||||
if (typeof val === "function") {
|
||||
const fnWrapper = function (...args) {
|
||||
logSet.add(`CALL: ${fullName}(${args.map((a) => JSON.stringify(a)).join(", ")})`);
|
||||
return val.apply(this === receiver ? obj : this, args);
|
||||
};
|
||||
fnWrapper.prototype = val.prototype;
|
||||
return fnWrapper;
|
||||
}
|
||||
return val;
|
||||
},
|
||||
set(obj, prop, value, receiver) {
|
||||
const fullName = `${prefix}.${String(prop)}`;
|
||||
logSet.add(`SET: ${fullName}`);
|
||||
return Reflect.set(obj, prop, value, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createVirtualDate(initialTime) {
|
||||
let currentTime = initialTime || (Date.now() - 3200);
|
||||
const RealDate = Date;
|
||||
|
||||
function FakeDate(...args) {
|
||||
if (!(this instanceof FakeDate)) {
|
||||
return new RealDate(currentTime).toString();
|
||||
}
|
||||
if (args.length === 0) {
|
||||
return new RealDate(currentTime);
|
||||
}
|
||||
return new RealDate(...args);
|
||||
}
|
||||
|
||||
FakeDate.prototype = Object.create(RealDate.prototype);
|
||||
FakeDate.now = () => currentTime;
|
||||
FakeDate.parse = RealDate.parse;
|
||||
FakeDate.UTC = RealDate.UTC;
|
||||
|
||||
return {
|
||||
FakeDate,
|
||||
advance: (ms) => {
|
||||
currentTime += ms;
|
||||
return currentTime;
|
||||
},
|
||||
now: () => currentTime,
|
||||
};
|
||||
}
|
||||
|
||||
function probeTdc(tdcFilePath, options = {}) {
|
||||
let tdcCode = options.tdcCode || fs.readFileSync(tdcFilePath, "utf-8");
|
||||
|
||||
// ---- 神谕钩子(自包含, 写在沙箱内的 window 上): 记录 PC, 并在进入 XTEA 例程时 dump 密钥寄存器 ----
|
||||
const TARGET_PC = parseInt(process.env.XTEA_PC || "9109", 10);
|
||||
const BASE = (process.env.BASE_KEY || "1817655617,1381395306,1245924197,1767660346");
|
||||
const HOOK = "window.__PC=window.__PC||[];window.__W=[" + BASE + "];"
|
||||
+ "window.__H=function(H,E){window.__PC.push(H);"
|
||||
+ "if(H<=" + TARGET_PC + "&&!window.__ARR){"
|
||||
+ "for(var i=0;i<E.length;i++){var c=E[i];var v=(c&&c.length===1)?c[0]:c;"
|
||||
+ "if(v&&v.length===4&&v[0]===window.__W[0]&&v[1]===window.__W[1]&&v[2]===window.__W[2]&&v[3]===window.__W[3])"
|
||||
+ "{window.__ARR={at:H,reg:i,prev:window.__PC.slice(-120)};break;}}}"
|
||||
+ "if(!window.__WP){window.__WP=[null,null,null,null];}"
|
||||
+ "for(var q=0;q<4;q++){if(window.__WP[q])continue;"
|
||||
+ "for(var i=0;i<E.length;i++){var c=E[i];var v=(c&&c.length===1)?c[0]:c;"
|
||||
+ "if(typeof v==='number'&&v===window.__W[q]){window.__WP[q]={at:H,reg:i};break;}}}"
|
||||
+ "if(H<=" + TARGET_PC + "&&!window.__WRD){var f=-1;"
|
||||
+ "for(var i=0;i<E.length;i++){var c=E[i];var v=(c&&c.length===1)?c[0]:c;"
|
||||
+ "if(typeof v==='number'&&v===window.__W[0]){f=i;break;}}"
|
||||
+ "if(f>=0)window.__WRD={at:H,reg:f,prev:window.__PC.slice(-120)};}"
|
||||
+ "};";
|
||||
// 可选: 在指定 PC dump 全部寄存器 (神谕二分/定位用)
|
||||
const DUMP_PC = parseInt(process.env.DUMP_PC || "-1", 10);
|
||||
if (DUMP_PC >= 0) {
|
||||
tdcCode = "window.__DPC=" + DUMP_PC + ";window.__DUMP=[];(function(){var _h=window.__H;"
|
||||
+ "window.__H=function(H,E){_h(H,E);if(H===window.__DPC&&window.__DUMP.length<8){var o=[];"
|
||||
+ "for(var i=0;i<E.length;i++){var c=E[i];var v=(c&&c.length===1)?c[0]:c;"
|
||||
+ "o.push(typeof v==='string'?'str:'+v:(typeof v==='number'?'num:'+v:String(v)));}"
|
||||
+ "window.__DUMP.push(o);}}})();" + tdcCode;
|
||||
}
|
||||
const NEEDLE = "M=G[z[H++]]()";
|
||||
if (tdcCode.includes(NEEDLE)) {
|
||||
tdcCode = HOOK + tdcCode.replace(NEEDLE, "M=(window.__H(H,E),G[z[H++]]())");
|
||||
}
|
||||
const accesses = new Set();
|
||||
let mouseHandler = null;
|
||||
|
||||
// Initialize Virtual Clock (defaults to ~3.2s before real Date.now())
|
||||
const vClock = createVirtualDate(options.startTime);
|
||||
|
||||
// Mock Realistic Environment (based on macOS Chrome success capture)
|
||||
const realScreen = {
|
||||
width: options.screenWidth || 2560,
|
||||
height: options.screenHeight || 1440,
|
||||
availWidth: options.screenWidth || 2560,
|
||||
availHeight: options.screenAvailHeight || 1345,
|
||||
colorDepth: 24,
|
||||
pixelDepth: 24,
|
||||
};
|
||||
|
||||
const realNavigator = {
|
||||
userAgent:
|
||||
options.userAgent ||
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36",
|
||||
platform: "MacIntel",
|
||||
languages: ["zh-CN", "zh", "fr", "de", "en"],
|
||||
language: "zh-CN",
|
||||
hardwareConcurrency: 10,
|
||||
cookieEnabled: true,
|
||||
};
|
||||
|
||||
const fakeCanvas = {
|
||||
getContext: (type) => {
|
||||
if (type.includes("webgl")) {
|
||||
return {
|
||||
getExtension: (name) => {
|
||||
if (name === "WEBGL_debug_renderer_info") {
|
||||
return { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getParameter: (param) => {
|
||||
if (param === 37445) return "Google Inc. (Apple)";
|
||||
if (param === 37446) {
|
||||
return "ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
getSupportedExtensions: () => ["WEBGL_debug_renderer_info"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
getImageData: () => ({ data: new Uint8ClampedArray(16) }),
|
||||
toDataURL: () => "data:image/png;base64,GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1PQinPL7Bi3TG0qE2CogAAAABJRU5ErkJggg==",
|
||||
};
|
||||
},
|
||||
toDataURL: () => "data:image/png;base64,GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1PQinPL7Bi3TG0qE2CogAAAABJRU5ErkJggg==",
|
||||
};
|
||||
|
||||
const fakeLoc = {
|
||||
href: "https://turing.captcha.gtimg.com/1/template/drag_ele.html?rand=1519713624347",
|
||||
hostname: "turing.captcha.gtimg.com",
|
||||
protocol: "https:",
|
||||
};
|
||||
|
||||
const fakeDoc = {
|
||||
referrer: options.referrer || "https://1ms.run/console/personal/benefits/check-in",
|
||||
characterSet: "UTF-8",
|
||||
documentElement: { clientWidth: 360, clientHeight: 360 },
|
||||
createElement: (tag) => {
|
||||
if (String(tag).toLowerCase() === "canvas") return fakeCanvas;
|
||||
return { appendChild: () => {}, style: {}, setAttribute: () => {} };
|
||||
},
|
||||
getElementById: () => null,
|
||||
cookie: "",
|
||||
};
|
||||
|
||||
Object.defineProperty(fakeDoc, "onmousemove", {
|
||||
set(fn) {
|
||||
mouseHandler = fn;
|
||||
},
|
||||
get() {
|
||||
return mouseHandler;
|
||||
},
|
||||
});
|
||||
|
||||
const baseWindow = {
|
||||
screen: realScreen,
|
||||
navigator: realNavigator,
|
||||
document: fakeDoc,
|
||||
location: fakeLoc,
|
||||
innerWidth: 360,
|
||||
innerHeight: 360,
|
||||
devicePixelRatio: 2,
|
||||
Date: vClock.FakeDate,
|
||||
Math: Math,
|
||||
String: String,
|
||||
Number: Number,
|
||||
Boolean: Boolean,
|
||||
Array: Array,
|
||||
Object: Object,
|
||||
Function: Function,
|
||||
RegExp: RegExp,
|
||||
JSON: JSON,
|
||||
Error: Error,
|
||||
TypeError: TypeError,
|
||||
Uint8Array: Uint8Array,
|
||||
Uint8ClampedArray: Uint8ClampedArray,
|
||||
parseInt: parseInt,
|
||||
parseFloat: parseFloat,
|
||||
encodeURIComponent: encodeURIComponent,
|
||||
decodeURIComponent: decodeURIComponent,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
setInterval: setInterval,
|
||||
clearInterval: clearInterval,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
|
||||
// Attach window.top to simulate running in an iframe
|
||||
baseWindow.top = {};
|
||||
baseWindow.window = baseWindow;
|
||||
baseWindow.self = baseWindow;
|
||||
|
||||
const proxiedWindow = createDeepProxy(baseWindow, "window", accesses);
|
||||
|
||||
// Execute in isolated V8 Context
|
||||
const sandbox = vm.createContext(proxiedWindow);
|
||||
|
||||
if (options.verbose) console.log(`Running TDC inside Semantic Probe...`);
|
||||
vm.runInContext(tdcCode, sandbox);
|
||||
|
||||
// Dispatch simulated human trajectory if clickPoints provided
|
||||
const clickPoints = options.clickPoints || options.trajectory || [];
|
||||
if (mouseHandler && clickPoints.length > 0) {
|
||||
const scale = 320.0 / 672.0;
|
||||
|
||||
// 1. Initial human observation hesitation (400 ~ 650ms)
|
||||
vClock.advance(Math.floor(420 + Math.random() * 200));
|
||||
|
||||
// Starting mouse position (natural bottom-ish area: x~140, y~300)
|
||||
let curX = Math.round(130 + (Math.random() - 0.5) * 30);
|
||||
let curY = Math.round(290 + (Math.random() - 0.5) * 20);
|
||||
|
||||
for (let pIdx = 0; pIdx < clickPoints.length; pIdx++) {
|
||||
const pt = clickPoints[pIdx];
|
||||
// Target coordinates in iframe frame (left offset ~20, top ~60)
|
||||
const targetX = Math.round(20 + pt[0] * scale + (Math.random() - 0.5) * 4);
|
||||
const targetY = Math.round(60 + pt[1] * scale + (Math.random() - 0.5) * 4);
|
||||
|
||||
const startStepX = curX;
|
||||
const startStepY = curY;
|
||||
// 15 ~ 22 movement steps per target point
|
||||
const steps = Math.floor(15 + Math.random() * 6);
|
||||
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
// Human kinematics: Ease-in-out cubic curve
|
||||
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
const jitterX = s < steps ? (Math.random() - 0.5) * 1.6 : 0;
|
||||
const jitterY = s < steps ? (Math.random() - 0.5) * 1.6 : 0;
|
||||
|
||||
curX = Math.round(startStepX + (targetX - startStepX) * ease + jitterX);
|
||||
curY = Math.round(startStepY + (targetY - startStepY) * ease + jitterY);
|
||||
|
||||
// Advance 16 ~ 18ms per frame (60Hz vsync sampling)
|
||||
vClock.advance(16 + (Math.random() < 0.25 ? 1 : 0));
|
||||
|
||||
mouseHandler({
|
||||
type: "mousemove",
|
||||
clientX: curX,
|
||||
clientY: curY,
|
||||
pageX: curX,
|
||||
pageY: curY,
|
||||
offsetX: curX,
|
||||
offsetY: curY,
|
||||
});
|
||||
}
|
||||
|
||||
// Pause at target (click dwell time: 90 ~ 160ms)
|
||||
const dwellMs = Math.floor(90 + Math.random() * 70);
|
||||
vClock.advance(dwellMs);
|
||||
mouseHandler({
|
||||
type: "mousemove",
|
||||
clientX: curX,
|
||||
clientY: curY,
|
||||
pageX: curX,
|
||||
pageY: curY,
|
||||
offsetX: curX,
|
||||
offsetY: curY,
|
||||
});
|
||||
}
|
||||
|
||||
// Post-click think & submit delay (200 ~ 350ms)
|
||||
vClock.advance(Math.floor(220 + Math.random() * 120));
|
||||
}
|
||||
|
||||
if (options.verbose) console.log(`TDC loaded. Calling TDC.getData(!0)...`);
|
||||
let collect = null;
|
||||
let eks = null;
|
||||
if (sandbox.TDC && sandbox.TDC.getData) {
|
||||
collect = sandbox.TDC.getData(true);
|
||||
if (sandbox.TDC.getInfo) {
|
||||
const info = sandbox.TDC.getInfo();
|
||||
eks = info ? info.info : null;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: !!collect,
|
||||
collect: collect,
|
||||
eks: eks,
|
||||
collectLen: collect ? collect.length : 0,
|
||||
collectPreview: collect ? collect.slice(0, 80) : null,
|
||||
eksPreview: eks ? eks.slice(0, 80) : null,
|
||||
accessCount: accesses.size,
|
||||
accesses: Array.from(accesses).sort(),
|
||||
__pc_len: (sandbox.__PC || []).length,
|
||||
__xtea: sandbox.__XT || null,
|
||||
__arr: sandbox.__ARR || null,
|
||||
__wrd: sandbox.__WRD || null,
|
||||
__dump: sandbox.__DUMP || null,
|
||||
__wp: sandbox.__WP || null,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const arg2 = process.argv[2];
|
||||
if (arg2 === "-") {
|
||||
// Pipeline mode for Python / external callers
|
||||
let tdcCode = "";
|
||||
process.stdin.setEncoding("utf-8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
tdcCode += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
const opts = process.argv[3] ? JSON.parse(process.argv[3]) : {};
|
||||
// Temporary write to memory / execute directly
|
||||
const report = probeTdc(null, { ...opts, tdcCode });
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
success: report.success,
|
||||
collect: report.collect,
|
||||
tlg: String(report.collectLen),
|
||||
eks: report.eks,
|
||||
source: "probe",
|
||||
}) + "\n"
|
||||
);
|
||||
} catch (err) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ success: false, error: String(err) }) + "\n"
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const tdcFile = arg2 || "tcaptcha/core_js/tdc_sample.js";
|
||||
const report = probeTdc(tdcFile);
|
||||
console.log("\n--- TDC Execution Result ---");
|
||||
console.log(`Success: ${report.success}`);
|
||||
console.log(`Collect Length: ${report.collectLen}`);
|
||||
console.log(`Collect Preview: ${report.collectPreview}`);
|
||||
console.log(`EKS Preview: ${report.eksPreview}`);
|
||||
console.log(`Total Environment Accesses: ${report.accessCount}`);
|
||||
|
||||
console.log("\n--- Top 35 Environment Accesses Logged by Probe ---");
|
||||
report.accesses.slice(0, 35).forEach((a) => console.log(a));
|
||||
|
||||
const outReport = tdcFile.replace(/\.js$/, ".probe_report.json");
|
||||
fs.writeFileSync(outReport, JSON.stringify(report, null, 2), "utf-8");
|
||||
console.log(`\nDetailed probe report written to ${outReport}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { probeTdc };
|
||||
@@ -1,432 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
tdc_program.py —— TDC Chaos VM 「程序结构层」分析器。
|
||||
|
||||
它补的是 VM_SPEC.md §5/§6 自己点出的缺口:
|
||||
opcode 表 / 闭包调用约定 早已吃透, 但**模块系统 + 宿主调用序列**一直没被静态说明:
|
||||
顶层只"装配模块", 真正干活的代码在闭包被宿主调用时才跑。
|
||||
|
||||
本工具把这些做出确定规格:
|
||||
* `closures` —— 全部 CREATE_CLOSURE 站点: (pc, target, caps[(dst,src)], params[reg])
|
||||
* `entries` —— 子程序入口集合 = {0} ∪ {所有 closure target}
|
||||
* `strings` —— 从字节码折叠出的字面串 (PUSH_EMPTY_STR + 连续 PUSH_STR_CHAR)
|
||||
* `owner(pc)` —— 某 pc 属于哪个子程序 (启发式: 覆盖它的、且 ≤pc 的最大入口)
|
||||
* `call_graph` —— 子程序 → 它在自己体内创建的闭包 target (静态调用图的"创建边")
|
||||
* `xfuncs` —— 同一 target 被多个站点创建的"共享体" (VM 里的复用函数)
|
||||
|
||||
注意 (踩过的坑):
|
||||
`_decode_one` 对 CREATE_CLOSURE 的 `operands` **不含 target**(target 只写在 note 里),
|
||||
必须自己按 `target = z[pc+1]` 取, 否则会把 cap 寄存器号当成 target。
|
||||
|
||||
用法:
|
||||
.venv/bin/python tools/tdc_program.py tcaptcha/core_js/tdc_sample.js --summary
|
||||
.venv/bin/python tools/tdc_program.py <js> --seq --max 30
|
||||
.venv/bin/python tools/tdc_program.py <js> --find getData
|
||||
.venv/bin/python tools/tdc_program.py <js> --at 1725
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tcaptcha.tdc_opcodes import derive_opcode_table # noqa: E402
|
||||
from tools.tdc_disasm import disassemble_cfg, extract_bytecode # noqa: E402
|
||||
|
||||
MIN_STR_LEN = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClosureSite:
|
||||
pc: int
|
||||
target: int
|
||||
caps: List[Tuple[int, int]] = field(default_factory=list) # (新闭包寄存器, 父闭包寄存器)
|
||||
params: List[int] = field(default_factory=list) # 形参落到的寄存器号
|
||||
|
||||
|
||||
@dataclass
|
||||
class Program:
|
||||
path: str
|
||||
z: List[int]
|
||||
names: Dict[int, str]
|
||||
arity: Dict[int, int]
|
||||
dynamic: set
|
||||
insts: Dict[int, dict]
|
||||
closures: List[ClosureSite]
|
||||
strings: List[Tuple[int, str]]
|
||||
entries: List[int]
|
||||
_owner: Dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# -------------------------------------------------------------- 基本查询
|
||||
def name(self, pc: int) -> str:
|
||||
i = self.insts.get(pc)
|
||||
return i["name"] if i else "?"
|
||||
|
||||
def owner(self, pc: int) -> int:
|
||||
"""pc 属于哪个子程序入口。
|
||||
|
||||
注意: 代码布局是「函数体之间夹着父函数的初始化序列 + 内联的子函数体」,
|
||||
所以**不能**用"覆盖它的最大入口 ≤pc"来判 (会把父函数续段误判给夹在中间的兄弟函数)。
|
||||
这里用**边界感知走法**: 从入口开始走控制流, 一旦走到**另一个函数入口**就停下
|
||||
(函数只能被调用进入, 不会被穿过)。
|
||||
"""
|
||||
if not self._owner:
|
||||
self._build_owners()
|
||||
return self._owner.get(pc, 0)
|
||||
|
||||
def _next_pc(self, pc: int, inst: dict) -> int:
|
||||
"""下一条 fall-through 指令的 pc。CREATE_CLOSURE 是动态长度, 不能用固定 arity。"""
|
||||
if inst["name"] == "CREATE_CLOSURE":
|
||||
capn, parn = self.z[pc + 2], self.z[pc + 3]
|
||||
return pc + 1 + 3 + 2 * capn + parn
|
||||
return pc + 1 + self.arity.get(inst["op"], 0)
|
||||
|
||||
def function_code(self, entry: int) -> List[dict]:
|
||||
"""边界感知地取某个子程序**自己的**指令序列。"""
|
||||
starts = set(self.entries)
|
||||
out: Dict[int, dict] = {}
|
||||
work = [entry]
|
||||
while work:
|
||||
pc = work.pop()
|
||||
if pc in out or pc >= len(self.z) or pc < 0:
|
||||
continue
|
||||
if pc != entry and pc in starts: # 进入别的函数体 -> 不跨越
|
||||
continue
|
||||
inst = self.insts.get(pc)
|
||||
if inst is None:
|
||||
continue
|
||||
out[pc] = inst
|
||||
name = inst["name"]
|
||||
ops = inst["operands"] or []
|
||||
if name == "JUMP":
|
||||
work.append(ops[0])
|
||||
continue
|
||||
if name == "JUMP_IF_TRUE":
|
||||
work.append(ops[0])
|
||||
work.append(self._next_pc(pc, inst))
|
||||
continue
|
||||
if name == "PUSH_TRY" and self.arity.get(inst["op"], 0) == 2:
|
||||
work.append(ops[0])
|
||||
if name == "CREATE_CLOSURE":
|
||||
work.append(self.z[pc + 1])
|
||||
if name in ("RET_TRUE", "THROW"):
|
||||
continue
|
||||
work.append(self._next_pc(pc, inst))
|
||||
return [out[k] for k in sorted(out)]
|
||||
|
||||
def _build_owners(self) -> None:
|
||||
for e in self.entries:
|
||||
for inst in self.function_code(e):
|
||||
self._owner.setdefault(inst["pc"], e)
|
||||
|
||||
def closures_of(self, entry: int) -> List[ClosureSite]:
|
||||
return [c for c in self.closures if self.owner(c.pc) == entry]
|
||||
|
||||
def call_graph(self) -> Dict[int, List[int]]:
|
||||
g: Dict[int, List[int]] = {}
|
||||
for c in self.closures:
|
||||
g.setdefault(self.owner(c.pc), []).append(c.target)
|
||||
return {k: sorted(set(v)) for k, v in g.items()}
|
||||
|
||||
def shared_bodies(self) -> Dict[int, List[int]]:
|
||||
"""target -> 创建它的站点列表 (长度>1 说明这些站点共用同一个代码体)。"""
|
||||
m: Dict[int, List[int]] = {}
|
||||
for c in self.closures:
|
||||
m.setdefault(c.target, []).append(c.pc)
|
||||
return {k: v for k, v in m.items() if len(v) > 1}
|
||||
|
||||
def module_registry(self, entry: int = 0) -> List[Tuple[int, int]]:
|
||||
"""识别顶层「模块注册表装配」模式, 返回 [(模块 id, 工厂闭包 entry), ...]。
|
||||
|
||||
模式 (源码级 `registry[id] = function(module, exports, require){...}` 的编译产物):
|
||||
DUP; PUSH_IMM [id]; REVERSE_TOP2; JUMP -> @F; @F: CREATE_CLOSURE ...; SET_PROP
|
||||
|
||||
这是整个程序的骨架: 顶层只把 228 个模块工厂按 id 填进一个数组, 入口再 drive 它。
|
||||
"""
|
||||
code = self.function_code(entry)
|
||||
pairs: List[Tuple[int, int]] = []
|
||||
for k, inst in enumerate(code):
|
||||
if inst["name"] != "CREATE_CLOSURE":
|
||||
continue
|
||||
for j in range(k - 1, max(-1, k - 4), -1):
|
||||
if code[j]["name"] != "JUMP":
|
||||
continue
|
||||
ops = code[j]["operands"] or []
|
||||
if not ops or ops[0] != inst["pc"]:
|
||||
continue
|
||||
for m in range(j - 1, max(-1, j - 6), -1):
|
||||
if code[m]["name"] == "PUSH_IMM" and code[m]["operands"]:
|
||||
# 记的是**工厂函数体入口** (CREATE_CLOSURE 的 target), 不是创建站点
|
||||
pairs.append((int(code[m]["operands"][0]), self.z[inst["pc"] + 1]))
|
||||
break
|
||||
break
|
||||
seen, out = set(), []
|
||||
for mid, tgt in sorted(pairs):
|
||||
if mid in seen:
|
||||
continue
|
||||
seen.add(mid)
|
||||
out.append((mid, tgt))
|
||||
return out
|
||||
|
||||
# ---------------------------------------------------------- 轻量符号求值
|
||||
def symbolic_assignments(self, entry: int, limit: int = 30) -> List[Tuple[str, str]]:
|
||||
"""对一个小函数(如采集器模块)做**线性符号求值**, 还原 `rN = <表达式>`。
|
||||
|
||||
只覆盖这类小模块实际用到的形状: 字面串 / BIND_THIS / MAKE_(REG_)PROP_REF /
|
||||
GET_PROP / GET_GLOBAL_PROP / NEW_FUNC / CALL_FUNC / CALL_METHOD / STORE_REG /
|
||||
LOAD_REG_IMM / REVERSE_TOP2 / DUP / 算术。
|
||||
|
||||
目的: 把"这一槽取的是什么值"变成可读路径, 例如 `c.navigator.userAgent`。
|
||||
说明: 遇到分支时按 pc 顺序近似处理, 结果用于**定位语义**, 不作为执行依据。
|
||||
"""
|
||||
regs: Dict[int, str] = {}
|
||||
out: List[Tuple[str, str]] = []
|
||||
st: List[Tuple[str, str]] = [] # ("reg", n) | ("sym", text)
|
||||
|
||||
def top() -> Tuple[str, str]:
|
||||
return st[-1] if st else ("sym", "?")
|
||||
|
||||
# 栈上元素形如 ("reg", int) | ("sym", str):`[1]` 可能是 int,
|
||||
# 插值进 f-string 前必须统一转 str(早期版本在 CALL_* 处直接 join 会 TypeError)。
|
||||
def txt(x: Tuple[str, object]) -> str:
|
||||
return str(x[1]) if isinstance(x, tuple) else str(x)
|
||||
|
||||
def popn(n: int) -> List[Tuple[str, str]]:
|
||||
r = st[-n:] if n else []
|
||||
del st[len(st) - n:]
|
||||
return r
|
||||
|
||||
for inst in self.function_code(entry):
|
||||
n, ops = inst["name"], inst["operands"] or []
|
||||
if n == "PUSH_IMM_ARRAY":
|
||||
st.append(("reg", ops[0]))
|
||||
elif n == "PUSH_EMPTY_STR":
|
||||
st.append(("sym", ""))
|
||||
elif n == "PUSH_STR_CHAR":
|
||||
k, v = top()
|
||||
st[-1] = ("sym", v + chr(ops[0] & 0xFF))
|
||||
elif n == "PUSH_IMM":
|
||||
st.append(("sym", str(ops[0])))
|
||||
elif n == "PUSH_UNDEFINED":
|
||||
st.append(("sym", "undefined"))
|
||||
elif n == "PUSH_NULL":
|
||||
st.append(("sym", "null"))
|
||||
elif n == "PUSH_TRUE":
|
||||
st.append(("sym", "true"))
|
||||
elif n == "PUSH_FALSE":
|
||||
st.append(("sym", "false"))
|
||||
elif n == "LOAD_REG_IMM":
|
||||
st.append(("sym", regs.get(ops[0], f"r{ops[0]}")))
|
||||
elif n == "LOAD_REG_INDIRECT":
|
||||
k, v = st.pop() if st else ("sym", "?")
|
||||
st.append(("sym", regs.get(int(v), f"r{v}") if str(v).isdigit() else v))
|
||||
elif n == "BIND_THIS":
|
||||
k, v = st.pop() if st else ("sym", "?")
|
||||
st.append(("sym", f"c.{txt(('sym', v))}"))
|
||||
elif n == "MAKE_PROP_REF":
|
||||
key = txt(top())
|
||||
popn(1)
|
||||
base = txt(top())
|
||||
popn(1)
|
||||
st.append(("sym", f"{base}.{key}"))
|
||||
elif n == "MAKE_REG_PROP_REF":
|
||||
key = txt(top())
|
||||
popn(1)
|
||||
k, v = st.pop() if st else ("sym", "?")
|
||||
st.append(("sym", f"{regs.get(v, f'r{v}')}.{key}"))
|
||||
elif n == "GET_GLOBAL_PROP":
|
||||
st[-1] = ("sym", f"c.{txt(top())}")
|
||||
elif n in ("GET_PROP", "GET_ELEM"):
|
||||
pass
|
||||
elif n in ("ADD", "SUB", "MUL", "DIV", "MOD", "BIT_AND", "BIT_OR", "BIT_XOR",
|
||||
"SHL", "SHR", "SHR_U", "EQ", "EQ_STRICT", "GREATER"):
|
||||
if len(st) >= 2:
|
||||
b = txt(top())
|
||||
popn(1)
|
||||
a = txt(top())
|
||||
popn(1)
|
||||
st.append(("sym", f"({a} {n} {b})"))
|
||||
elif n in ("NEW_FUNC", "NEW_METHOD"):
|
||||
args = popn(int(ops[0]) if ops else 0)
|
||||
fn = txt(st.pop()) if st else "?"
|
||||
st.append(("sym", f"new {fn}({','.join(txt(x) for x in args)})"))
|
||||
elif n == "CALL_FUNC":
|
||||
args = popn(int(ops[0]) if ops else 0)
|
||||
fn = txt(st.pop()) if st else "?"
|
||||
st.append(("sym", f"{fn}({','.join(txt(x) for x in args)})"))
|
||||
elif n == "CALL_METHOD":
|
||||
args = popn(int(ops[0]) if ops else 0)
|
||||
tgt = txt(st.pop()) if st else "?"
|
||||
st.append(("sym", f"{tgt}({','.join(txt(x) for x in args)})"))
|
||||
elif n == "STORE_REG":
|
||||
v = txt(st.pop()) if st else "?"
|
||||
k, ref = st.pop() if st else ("sym", "?")
|
||||
if k == "reg":
|
||||
regs[ref] = v
|
||||
out.append((f"r{ref}", v))
|
||||
elif n == "DUP":
|
||||
if st:
|
||||
st.append(st[-1])
|
||||
elif n == "REVERSE_TOP2":
|
||||
if len(st) >= 2:
|
||||
st[-1], st[-2] = st[-2], st[-1]
|
||||
elif n in ("POP", "SET_PROP", "ALLOC_REG", "SET_STACK_LEN", "REPLACE_TOP_IMM"):
|
||||
if n in ("POP", "SET_PROP") and st:
|
||||
st.pop()
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
def find_strings(self, pattern: str) -> List[Tuple[int, str, int]]:
|
||||
"""返回 (pc, 串, 所属子程序入口)。"""
|
||||
rx = re.compile(pattern, re.I)
|
||||
return [(pc, s, self.owner(pc)) for pc, s in self.strings if rx.search(s)]
|
||||
|
||||
|
||||
def _fold_strings(insts: Dict[int, dict]) -> List[Tuple[int, str]]:
|
||||
"""PUSH_EMPTY_STR 后跟一串 PUSH_STR_CHAR -> 还原字面串 (tdc_disasm 不做这步)。"""
|
||||
out: List[Tuple[int, str]] = []
|
||||
cur_pc: Optional[int] = None
|
||||
buf: List[str] = []
|
||||
for pc in sorted(insts):
|
||||
i = insts[pc]
|
||||
n = i["name"]
|
||||
if n == "PUSH_EMPTY_STR":
|
||||
cur_pc, buf = pc, []
|
||||
continue
|
||||
if n == "PUSH_STR_CHAR" and cur_pc is not None:
|
||||
ops = i["operands"] or []
|
||||
if ops:
|
||||
buf.append(chr(ops[0] & 0xFF))
|
||||
continue
|
||||
if cur_pc is not None:
|
||||
if len(buf) >= MIN_STR_LEN:
|
||||
out.append((cur_pc, "".join(buf)))
|
||||
cur_pc, buf = None, []
|
||||
if cur_pc is not None and len(buf) >= MIN_STR_LEN:
|
||||
out.append((cur_pc, "".join(buf)))
|
||||
return out
|
||||
|
||||
|
||||
def load(js_path: str) -> Program:
|
||||
js = open(js_path, encoding="utf-8", errors="replace").read()
|
||||
z = extract_bytecode(js)
|
||||
names, arity, dynamic, _diag = derive_opcode_table(js)
|
||||
insts = {i["pc"]: i for i in
|
||||
disassemble_cfg(z, 0, True, names=names, arity=arity, dynamic=dynamic)
|
||||
if i["op"] is not None}
|
||||
|
||||
closures: List[ClosureSite] = []
|
||||
for pc, i in insts.items():
|
||||
if i["name"] != "CREATE_CLOSURE":
|
||||
continue
|
||||
target, cap, par = z[pc + 1], z[pc + 2], z[pc + 3]
|
||||
base = pc + 4
|
||||
caps = [(z[base + 2 * k], z[base + 2 * k + 1]) for k in range(cap)]
|
||||
params = [z[base + 2 * cap + k] for k in range(par)]
|
||||
closures.append(ClosureSite(pc=pc, target=target, caps=caps, params=params))
|
||||
|
||||
strings = _fold_strings(insts)
|
||||
entries = sorted({0} | {c.target for c in closures})
|
||||
return Program(path=js_path, z=z, names=names, arity=arity, dynamic=dynamic,
|
||||
insts=insts, closures=sorted(closures, key=lambda c: c.pc),
|
||||
strings=strings, entries=entries)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- CLI
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="TDC Chaos VM 程序结构分析")
|
||||
ap.add_argument("path", nargs="?", default=str(ROOT / "tcaptcha/core_js/tdc_sample.js"))
|
||||
ap.add_argument("--summary", action="store_true", help="总体规模")
|
||||
ap.add_argument("--seq", action="store_true", help="按子程序列出闭包站点")
|
||||
ap.add_argument("--max", type=int, default=40, help="--seq/--find 的截断条数")
|
||||
ap.add_argument("--find", default=None, help="按正则找字面串")
|
||||
ap.add_argument("--at", type=int, default=None, help="看某个 pc 属于谁")
|
||||
ap.add_argument("--shared", action="store_true", help="列出被多个站点共享的闭包体")
|
||||
ap.add_argument("--func", type=int, default=None, help="打印某个子程序**自己的**指令序列")
|
||||
ap.add_argument("--asm-out", default=None, help="--func 时把反汇编写到该文件")
|
||||
ap.add_argument("--modules", action="store_true", help="导出模块注册表 (id -> 工厂 entry)")
|
||||
args = ap.parse_args()
|
||||
|
||||
p = load(args.path)
|
||||
shown = False
|
||||
|
||||
if args.summary or not any([args.seq, args.find is not None, args.at is not None,
|
||||
args.shared, args.func is not None]):
|
||||
shown = True
|
||||
print(f"file : {p.path}")
|
||||
print(f"bytecode : {len(p.z)} ints")
|
||||
print(f"reachable instrs: {len(p.insts)}")
|
||||
print(f"closure sites : {len(p.closures)}")
|
||||
print(f"subprograms : {len(p.entries)} (0 为顶层)")
|
||||
print(f"distinct bodies : {len({c.target for c in p.closures})}")
|
||||
print(f"literal strings : {len(p.strings)} (>= {MIN_STR_LEN} 字符)")
|
||||
|
||||
if args.seq:
|
||||
shown = True
|
||||
g = p.call_graph()
|
||||
print(f"\n--- 子程序与它们创建的闭包 (前 {args.max} 个入口) ---")
|
||||
for e in p.entries[: args.max]:
|
||||
kids = g.get(e, [])
|
||||
st = [s for pc, s in p.strings if p.owner(pc) == e]
|
||||
head = ", ".join(f"{s[:22]!r}" for s in st[:4])
|
||||
print(f" @{e:05d} -> {kids}")
|
||||
if head:
|
||||
print(f" 串: {head}")
|
||||
|
||||
if args.find is not None:
|
||||
shown = True
|
||||
hits = p.find_strings(args.find)
|
||||
print(f"\n--- 字面串匹配 {args.find!r}: {len(hits)} 条 ---")
|
||||
for pc, s, own in hits[: args.max]:
|
||||
print(f" @{pc:05d} [子程序 @{own:05d}] {s[:90]!r}")
|
||||
|
||||
if args.at is not None:
|
||||
shown = True
|
||||
pc = args.at
|
||||
print(f"\n--- pc @{pc} ---")
|
||||
print(f" 指令 : {p.insts.get(pc)}")
|
||||
print(f" 所属 : @{p.owner(pc)}")
|
||||
|
||||
if args.func is not None:
|
||||
shown = True
|
||||
code = p.function_code(args.func)
|
||||
lines = []
|
||||
for x in code:
|
||||
extra = (" " + " ".join(x["note"])) if x["note"] else (
|
||||
(" " + str(x["operands"])) if x["operands"] else "")
|
||||
lines.append(f"@{x['pc']:05d}: {x['name']:<18}{extra}")
|
||||
print(f"\n--- 子程序 @{args.func} 自身指令: {len(lines)} 条 ---")
|
||||
print("\n".join(lines[: args.max]))
|
||||
if args.asm_out:
|
||||
Path(args.asm_out).write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f" -> {args.asm_out}")
|
||||
|
||||
if args.modules:
|
||||
shown = True
|
||||
reg = p.module_registry(0)
|
||||
print(f"\n--- 模块注册表: {len(reg)} 个模块 (registry[id] = 工厂闭包) ---")
|
||||
for mid, tgt in reg[: args.max]:
|
||||
print(f" registry[{mid:3d}] = 工厂 @{tgt}")
|
||||
if len(reg) > args.max:
|
||||
print(f" ... 共 {len(reg)} 个")
|
||||
|
||||
if args.shared:
|
||||
shown = True
|
||||
sh = p.shared_bodies()
|
||||
print(f"\n--- 共享闭包体: {len(sh)} 个 target 被多处创建 ---")
|
||||
for t, sites in sorted(sh.items(), key=lambda kv: -len(kv[1]))[: args.max]:
|
||||
print(f" target @{t:05d} x{len(sites)} sites={sites[:8]}{'...' if len(sites) > 8 else ''}")
|
||||
|
||||
return 0 if shown else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
TDC VM 工具链 ⑤ 查询 CLI —— 对齐 AGENTS.md §4.2 验收标准
|
||||
|
||||
验收要求能秒答三个问题:
|
||||
* 「这个字段/寄存器谁写的」 -> who <rN>
|
||||
* 「这段代码在哪(属于哪个函数)」 -> at @pc
|
||||
* 「有多少个子程序/闭包」 -> funcs
|
||||
另附 grep 正则查询。
|
||||
|
||||
用法(先跑 tools/tdc_lift.py 生成 <name>.lifted.txt):
|
||||
.venv/bin/python tools/tdc_query.py <lifted.txt> who r7
|
||||
.venv/bin/python tools/tdc_query.py <lifted.txt> at @9285
|
||||
.venv/bin/python tools/tdc_query.py <lifted.txt> funcs
|
||||
.venv/bin/python tools/tdc_query.py <lifted.txt> grep ">>> 11"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
def parse(path: str):
|
||||
"""切分 lifted 文件 -> [(func_name, [ (pc或None, 行文本) ])]"""
|
||||
funcs: List[Tuple[str, List[Tuple[int, str]]]] = []
|
||||
cur_name, cur_lines = None, []
|
||||
cur_pc = [-1]
|
||||
for raw in open(path, encoding="utf-8"):
|
||||
line = raw.rstrip("\n")
|
||||
m = re.match(r"\s*function\s+([A-Za-z0-9_]+)\(\)\s*\{", line)
|
||||
if m:
|
||||
if cur_name is not None:
|
||||
funcs.append((cur_name, cur_lines))
|
||||
cur_name, cur_lines = m.group(1), []
|
||||
continue
|
||||
if line.strip() == "}" and cur_name is not None:
|
||||
funcs.append((cur_name, cur_lines))
|
||||
cur_name, cur_lines = None, []
|
||||
continue
|
||||
if cur_name is not None:
|
||||
lm = re.match(r"\s*@(\d+):", line)
|
||||
if lm:
|
||||
cur_pc[0] = int(lm.group(1))
|
||||
cur_lines.append((cur_pc[0], line))
|
||||
if cur_name is not None:
|
||||
funcs.append((cur_name, cur_lines))
|
||||
return funcs
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__)
|
||||
return 2
|
||||
path, cmd = sys.argv[1], sys.argv[2]
|
||||
arg = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
funcs = parse(path)
|
||||
print(f"[{path}] 解析出 {len(funcs)} 个函数")
|
||||
|
||||
if cmd == "funcs":
|
||||
for name, lines in funcs:
|
||||
pcs = [p for p, _ in lines if p >= 0]
|
||||
print(f" {name:16s} 行数={len(lines):5d} pc范围=@{min(pcs) if pcs else '-'}..@{max(pcs) if pcs else '-'}")
|
||||
return 0
|
||||
|
||||
if cmd == "who":
|
||||
# 谁写了 rN (赋值/属性写)
|
||||
pat = re.compile(rf"(^|[^\w]){re.escape(arg)}\s*(=[^=]|\[[^\]]*\]\s*=)")
|
||||
hits = 0
|
||||
for name, lines in funcs:
|
||||
for pc, line in lines:
|
||||
if pat.search(line):
|
||||
print(f" {name} @{pc if pc>=0 else '?'}: {line.strip()}")
|
||||
hits += 1
|
||||
if not hits:
|
||||
print(f" 未发现写 {arg} 的语句")
|
||||
return 0
|
||||
|
||||
if cmd == "at":
|
||||
want = int(arg.lstrip("@"))
|
||||
for name, lines in funcs:
|
||||
pcs = [p for p, _ in lines if p >= 0]
|
||||
if not pcs or pcs[0] > want:
|
||||
continue
|
||||
nxt = [p for p in pcs if p >= want]
|
||||
if nxt and (want <= max(pcs)):
|
||||
print(f" @{want} 属于函数 {name} (该函数 pc 范围 @{min(pcs)}..@{max(pcs)})")
|
||||
# 打印附近 8 行
|
||||
for pc, line in lines:
|
||||
if 0 <= pc and want <= pc <= want + 20:
|
||||
print(" " + line.strip())
|
||||
break
|
||||
return 0
|
||||
|
||||
if cmd == "grep":
|
||||
pat = re.compile(arg)
|
||||
for name, lines in funcs:
|
||||
for pc, line in lines:
|
||||
if pat.search(line):
|
||||
print(f" {name} @{pc if pc>=0 else '?'}: {line.strip()}")
|
||||
return 0
|
||||
|
||||
print(f"未知命令: {cmd}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,174 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
tdc_slotmap.py —— 把「清单项」精确映射到「cd 槽号」。
|
||||
|
||||
为什么需要单独一层(承 har/1ms_dianxuan/PROGRESS.md #34/#35/#36):
|
||||
#34 槽位序 = 清单模块的 require 顺序(追加式数组,已核)
|
||||
#36 **槽数 ≠ 项数**: 装配循环里 `kind=1` 的分支是 `out += v`(不加引号直接拼),
|
||||
所以某些项的返回值**本身带逗号**时会一次性产出多个槽。
|
||||
实测同一构建: 37 项 -> 38 槽,多出的那一处是第 33 项(模块 1)
|
||||
get() 返回 "641547489,1791732001"。
|
||||
|
||||
因此映射不能是"第 i 项 = 第 i 槽",必须按每个采集器的**返回值形状**推算:
|
||||
|
||||
kind = ret[2](可能缺省)
|
||||
kind == 1: ret[0] 原样拼 -> 占 1 + ret[0].count(",") 槽(数字则 1 槽)
|
||||
其它: ret[0] 占 1 槽
|
||||
ret[1] 是 number: 再 +1 槽(实测 kind=2 的项第二元素是 null,不触发)
|
||||
|
||||
本模块提供:
|
||||
* `slot_map(prog, items)` —— 给静态导出的项 + 运行时取样到的返回值形状,算出映射
|
||||
* `from_runtime_items(prog)` —— 需要浏览器环境(iframe 里读清单各项 get()),见 CLI
|
||||
* CLI 校验模式: 与一份已解密的 collect 明文对拍,确认推算出的槽数 == 实际槽数
|
||||
|
||||
用法:
|
||||
# 用运行时取样 + 本次 getData 解密出的明文, 做一次端到端对拍
|
||||
.venv/bin/python tools/tdc_slotmap.py <tdc.js> \\
|
||||
--items /tmp/items.json --plain /tmp/plain.txt
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
if str(ROOT / "tools") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
import tdc_layout # noqa: E402
|
||||
import tdc_program as tp # noqa: E402
|
||||
|
||||
|
||||
def slots_of_item(ret) -> int:
|
||||
"""一个采集器的 get() 返回值会占几个 cd 槽。
|
||||
|
||||
ret 是运行时取到的值(或其形状描述)。规则见 PROGRESS.md #36。
|
||||
"""
|
||||
if ret is None:
|
||||
return 1
|
||||
if not isinstance(ret, (list, tuple)) or not ret:
|
||||
return 1
|
||||
first = ret[0]
|
||||
kind = ret[2] if len(ret) >= 3 else None
|
||||
n = 1
|
||||
if kind == 1 and isinstance(first, str):
|
||||
# 不加引号直接拼 -> 串里的逗号会切出额外槽
|
||||
n += first.count(",")
|
||||
# 第二个元素是 number 时再占一槽(实测 kind=2 的第二元素是 null,不触发)
|
||||
if len(ret) >= 2 and isinstance(ret[1], (int, float)) and not isinstance(ret[1], bool):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def slot_map(seq: Sequence[int], rets: Sequence) -> List[Tuple[int, int, int]]:
|
||||
"""返回 [(清单项下标, 模块 id, 该首槽在 cd 中的槽号)]。
|
||||
|
||||
某一项占多槽时,只给它的**首槽**;其余槽是你按 ret 内容自然拼出来的。
|
||||
"""
|
||||
out: List[Tuple[int, int, int]] = []
|
||||
slot = 0
|
||||
for i, m in enumerate(seq):
|
||||
out.append((i, m, slot))
|
||||
slot += slots_of_item(rets[i] if i < len(rets) else None)
|
||||
return out
|
||||
|
||||
|
||||
def total_slots(seq: Sequence[int], rets: Sequence) -> int:
|
||||
return sum(slots_of_item(rets[i] if i < len(rets) else None) for i in range(len(seq)))
|
||||
|
||||
|
||||
def load_items(path: str) -> List:
|
||||
"""读 cdp_eval 的取样输出({items:[{head,types,len,...}]})。
|
||||
|
||||
运行时只能带回 head(截断过),所以这里按 types/head 重建一个**够用的**形状。
|
||||
"""
|
||||
d = json.load(open(path, encoding="utf-8"))
|
||||
res = d.get("result", d)
|
||||
items = res.get("items") or []
|
||||
rets = []
|
||||
for it in items:
|
||||
types = it.get("types")
|
||||
head = it.get("head")
|
||||
if not isinstance(types, list):
|
||||
rets.append(None)
|
||||
continue
|
||||
rebuilt = []
|
||||
for t, h in zip(types, head or []):
|
||||
if t == "string":
|
||||
rebuilt.append(h if isinstance(h, str) else str(h))
|
||||
elif t == "number":
|
||||
rebuilt.append(h if isinstance(h, (int, float)) else 0)
|
||||
elif t == "null":
|
||||
rebuilt.append(None)
|
||||
elif t == "array":
|
||||
rebuilt.append([])
|
||||
else:
|
||||
rebuilt.append(None)
|
||||
rets.append(rebuilt)
|
||||
return rets
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="清单项 -> cd 槽号的精确映射")
|
||||
ap.add_argument("path")
|
||||
ap.add_argument("--items", default=None, help="运行时取样的 items.json")
|
||||
ap.add_argument("--plain", default=None, help="已解密的 collect 明文(用于对拍校验)")
|
||||
ap.add_argument("--json", default=None, help="把映射写成 JSON")
|
||||
args = ap.parse_args()
|
||||
|
||||
prog = tp.load(args.path)
|
||||
reg = dict(prog.module_registry(0))
|
||||
_mid, _entry, seq = tdc_layout.find_manifest_module(prog)
|
||||
rets: List = load_items(args.items) if args.items else []
|
||||
|
||||
print(f"文件 : {args.path}")
|
||||
print(f"清单模块 : registry[{_mid}]")
|
||||
print(f"清单项数 : {len(seq)}")
|
||||
if args.items:
|
||||
print(f"运行时取样 : {len(rets)} 项")
|
||||
print(f"推算 cd 槽数 : {total_slots(seq, rets)}")
|
||||
|
||||
if rets:
|
||||
print("\n项 模块 工厂 取到的值(截断) 占槽 首槽")
|
||||
for i, m in enumerate(seq):
|
||||
e = reg.get(m)
|
||||
r = rets[i] if i < len(rets) else None
|
||||
s = str(r)[:36]
|
||||
n = slots_of_item(r)
|
||||
start = slot_map(seq, rets)[i][2]
|
||||
print(f"{i:2d} {m:3d} @{e:<6} {s:<38} {n:^3} {start:2d}")
|
||||
|
||||
if args.plain:
|
||||
cd = json.loads(open(args.plain, encoding="latin1").read()
|
||||
.rstrip("\x00").strip()).get("cd") or []
|
||||
print(f"\n明文实际 cd 槽数: {len(cd)}")
|
||||
if rets:
|
||||
ok = len(cd) == total_slots(seq, rets)
|
||||
print(f">>> 推算 == 实际 ? {ok}")
|
||||
if not ok:
|
||||
print(" (差 %d 槽: 检查是否有项返回值的逗号计数没算准)" % (len(cd) - total_slots(seq, rets)))
|
||||
else:
|
||||
print("\n逐项 -> 槽对位:")
|
||||
sm = slot_map(seq, rets)
|
||||
for (i, m, st) in sm:
|
||||
n = slots_of_item(rets[i])
|
||||
vals = [repr(cd[st + k])[:24] for k in range(min(n, len(cd) - st))]
|
||||
print(f" 项{i:2d}(模块{m:2d}) -> 槽 {st}..{st + n - 1} {' | '.join(vals)}")
|
||||
|
||||
if args.json and rets:
|
||||
Path(args.json).write_text(json.dumps(
|
||||
{"manifestModule": _mid, "seq": list(seq),
|
||||
"map": [{"item": i, "module": m, "firstSlot": st, "slots": slots_of_item(rets[i])}
|
||||
for (i, m, st) in slot_map(seq, rets)]},
|
||||
ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
print(f"映射 -> {args.json}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 真值 PC 轨迹器 —— 给 tdc.js 的分发器打补丁, dump 真实执行的 PC 序列。
|
||||
*
|
||||
* 补丁点(源码实测): `for(var M=!1;!M;)M=G[z[H++]]();`
|
||||
* -> 记录 H(即将执行的 PC), 再正常分发。
|
||||
*
|
||||
* 用途: 与 tools/tdc_vm_run.py (纯 Python 解释器) 的 PC 序列**逐条对拍**,
|
||||
* 第一个不同的 PC 即执行流分歧点。
|
||||
*
|
||||
* 用法: node tools/tdc_trace.js <tdc.js> [最大记录数] > /tmp/real_pcs.json
|
||||
*/
|
||||
const fs = require("fs");
|
||||
|
||||
const file = process.argv[2] || "tcaptcha/core_js/tdc_sample.js";
|
||||
const limit = parseInt(process.argv[3] || "200000", 10);
|
||||
|
||||
let src = fs.readFileSync(file, "utf8");
|
||||
const needle = "M=G[z[H++]]()";
|
||||
if (!src.includes(needle)) {
|
||||
console.error("[!] 未找到分发器文本: " + needle);
|
||||
process.exit(2);
|
||||
}
|
||||
src = src.replace(needle, "M=(globalThis.__PC.push(H),G[z[H++]]())");
|
||||
|
||||
globalThis.__PC = [];
|
||||
globalThis.window = globalThis;
|
||||
// 顶层引导阶段不需要真环境; 给最小桩以防早退
|
||||
globalThis.document = { cookie: "", referrer: "", createElement: () => ({ getContext: () => null, toDataURL: () => "", style: {} }), getElementById: () => null };
|
||||
globalThis.navigator = { userAgent: "Mozilla/5.0", platform: "MacIntel", languages: ["zh-CN", "zh"], language: "zh-CN", hardwareConcurrency: 10, cookieEnabled: true };
|
||||
globalThis.screen = { width: 2560, height: 1440, availWidth: 2560, availHeight: 1345, colorDepth: 24, pixelDepth: 24 };
|
||||
globalThis.location = { href: "https://turing.captcha.gtimg.com/1/template/drag_ele.html", hostname: "turing.captcha.gtimg.com", protocol: "https:" };
|
||||
globalThis.localStorage = { getItem: () => null, setItem: () => {} };
|
||||
globalThis.sessionStorage = { getItem: () => null, setItem: () => {} };
|
||||
globalThis.requestAnimationFrame = (f) => setTimeout(f, 0);
|
||||
globalThis.addEventListener = () => {};
|
||||
globalThis.removeEventListener = () => {};
|
||||
globalThis.__TENCENT_CHAOS_STACK = {};
|
||||
|
||||
let err = null;
|
||||
try {
|
||||
eval(src);
|
||||
} catch (e) {
|
||||
err = String(e && e.stack || e);
|
||||
}
|
||||
const pcs = globalThis.__PC.slice(0, limit);
|
||||
process.stderr.write(`[trace] 执行 PC 数=${globalThis.__PC.length}${err ? " 顶层抛错: " + err.slice(0, 160) : ""}\n`);
|
||||
process.stdout.write(JSON.stringify(pcs));
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* [已废弃] 旧 TDC VM 反汇编器 (线性扫描, 依赖 Node + Babel)
|
||||
*
|
||||
* 实测它有两个缺陷, 会产出**错误**的反汇编, 故已废弃:
|
||||
* 1. opcode 表是手抄的, 而且对字节码里的**数据区**做线性扫描 —— 一旦扫进数据区就失步,
|
||||
* 产出 73 处 UNKNOWN (其中 16 处是 >69 的"不可能" opcode);
|
||||
* 2. 未把 CREATE_CLOSURE(57) 的闭包体 (target) 作为遍历根 —— 导致大半个程序不可达。
|
||||
*
|
||||
* 请改用**纯 Python** 的正确实现 (可达性 CFG 反汇编, 0 处非法 opcode):
|
||||
* .venv/bin/python tools/tdc_disasm.py <tdc.js>
|
||||
*
|
||||
* 本文件仅保留为兼容入口: 直接转发给上面的 Python 工具。
|
||||
*/
|
||||
const { spawnSync } = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
const tdcFile = process.argv[2] || "tcaptcha/core_js/tdc_sample.js";
|
||||
const tool = path.join(__dirname, "tdc_disasm.py");
|
||||
|
||||
console.error("[deprecated] tools/tdc_vm_disasm.js 已废弃 -> 转发到 tools/tdc_disasm.py (纯 Python)");
|
||||
const r = spawnSync("python3", [tool, tdcFile], { stdio: "inherit" });
|
||||
if (r.error) {
|
||||
console.error("无法启动 python3; 请直接运行: .venv/bin/python tools/tdc_disasm.py " + tdcFile);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(r.status == null ? 1 : r.status);
|
||||
@@ -1,681 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
腾讯天御 Chaos VM —— **纯 Python 具体执行器** (目标: 静态解出 XTEA 密钥, 去掉 Node 依赖)
|
||||
|
||||
思路
|
||||
----
|
||||
密钥字是 4 个 "ASCII 打包的 4 字符串", 但既不是立即数、也不是任何字面字符串/其子串,
|
||||
说明它是**运行时算出来的**。所以这里把 VM 的 70 个 opcode 语义 (由 handlers 源码逐一确认)
|
||||
在 Python 里重新实现, 直接跑字节码, 把寄存器里出现的 "4 个可打印字符拼成的 32bit 数组" 抓出来。
|
||||
|
||||
VM 模型 (与源码一致):
|
||||
* `E` 同时是**寄存器区** (E[0..n-1]) 与**求值栈** (E[n..]);
|
||||
* 寄存器单元是长度为 1 的数组: 值 = E[i][0];
|
||||
* `z` = 字节码, `H` = PC, `c` = 宿主全局对象 (window)。
|
||||
|
||||
用法:
|
||||
.venv/bin/python tools/tdc_vm_run.py tcaptcha/core_js/tdc_sample.js
|
||||
KEY=<已知密钥> .venv/bin/python tools/tdc_vm_run.py tdc.js # 命中即打印所在寄存器
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from tdc_disasm import extract_bytecode # noqa: E402
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# JS 风格容器: 既支持整数下标, 也支持字符串属性
|
||||
# --------------------------------------------------------------------------- #
|
||||
class JArr(list):
|
||||
def __init__(self, *a):
|
||||
super().__init__(*a)
|
||||
self._ext: Dict[str, Any] = {}
|
||||
|
||||
def __getitem__(self, k):
|
||||
# JS 数组/对象: 缺失的属性是 undefined (不是 null!), 这点必须严格
|
||||
if isinstance(k, int) or (isinstance(k, str) and k.lstrip("-").isdigit()):
|
||||
i = int(k)
|
||||
try:
|
||||
return list.__getitem__(self, i)
|
||||
except IndexError:
|
||||
return JS_UNDEF
|
||||
if k in self._ext:
|
||||
return self._ext[k]
|
||||
return JS_UNDEF
|
||||
|
||||
def __setitem__(self, k, v):
|
||||
if isinstance(k, int) or (isinstance(k, str) and k.lstrip("-").isdigit()):
|
||||
i = int(k)
|
||||
if 0 <= i < len(self):
|
||||
list.__setitem__(self, i, v)
|
||||
else:
|
||||
self._ext[str(i)] = v
|
||||
else:
|
||||
self._ext[k] = v
|
||||
|
||||
|
||||
JS_UNDEF = object()
|
||||
|
||||
VM_VERSION = 0 # 对应源码里的 __TENCENT_CHAOS_VM.v
|
||||
|
||||
|
||||
class Stub:
|
||||
"""未知宿主对象: 可调用、可属性访问、可下标; **会真正记住被赋的属性**
|
||||
(tdc.js 把模块工厂挂到对象上, 若丢弃就永远找不到模块)。"""
|
||||
|
||||
def __init__(self, name="stub"):
|
||||
self._name = name
|
||||
self._props: Dict[Any, Any] = {}
|
||||
|
||||
def __call__(self, *a, **k):
|
||||
return Stub(self._name + "()")
|
||||
|
||||
def __getattr__(self, k):
|
||||
if k.startswith("_"):
|
||||
raise AttributeError(k)
|
||||
return self[k]
|
||||
|
||||
def __getitem__(self, k):
|
||||
if k in self._props:
|
||||
return self._props[k]
|
||||
v = Stub(f"{self._name}.{k}")
|
||||
self._props[k] = v
|
||||
return v
|
||||
|
||||
def __setitem__(self, k, v):
|
||||
self._props[k] = v
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self._name} props={list(self._props)[:5]}>"
|
||||
|
||||
|
||||
class HostFn:
|
||||
"""宿主内置函数/构造器 (Object/Array/String/...)。"""
|
||||
|
||||
def __init__(self, name, fn):
|
||||
self._name = name
|
||||
self._fn = fn
|
||||
|
||||
def __call__(self, *a):
|
||||
return self._fn(*a)
|
||||
|
||||
def __getitem__(self, k):
|
||||
return Stub(f"{self._name}[{k}]")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<host {self._name}>"
|
||||
|
||||
|
||||
def _mk_array(*a):
|
||||
if len(a) == 1 and isinstance(a[0], int):
|
||||
return JArr([JS_UNDEF] * a[0])
|
||||
return JArr(a)
|
||||
|
||||
|
||||
def _mk_object(*a):
|
||||
return JArr()
|
||||
|
||||
|
||||
def _to_host(**kw):
|
||||
o = JArr()
|
||||
o._ext.update(kw)
|
||||
return o
|
||||
|
||||
|
||||
def make_host() -> Any:
|
||||
"""最小宿主 (c = window)。未知项退化为 Stub, 保证不因缺属性而崩。"""
|
||||
win = _to_host()
|
||||
|
||||
win._ext.update({
|
||||
"Math": Stub("Math"),
|
||||
"JSON": type("J", (), {"parse": staticmethod(json.loads), "stringify": staticmethod(json.dumps)})(),
|
||||
"Object": HostFn("Object", _mk_object),
|
||||
"Array": HostFn("Array", _mk_array),
|
||||
"String": HostFn("String", lambda *a: "" if not a else str(a[0])),
|
||||
"Number": HostFn("Number", lambda *a: 0 if not a else float(a[0])),
|
||||
"Boolean": HostFn("Boolean", lambda *a: bool(a[0]) if a else False),
|
||||
"Date": HostFn("Date", lambda *a: int(time.time() * 1000)),
|
||||
"RegExp": Stub("RegExp"),
|
||||
"Error": Stub("Error"),
|
||||
"TypeError": Stub("TypeError"),
|
||||
"Uint8Array": HostFn("Uint8Array", lambda *a: JArr([0] * (a[0] if a and isinstance(a[0], int) else 0))),
|
||||
"Uint8ClampedArray": HostFn("Uint8ClampedArray", lambda *a: JArr([0] * (a[0] if a and isinstance(a[0], int) else 0))),
|
||||
"parseInt": HostFn("parseInt", lambda *a: int(float(a[0])) if a else 0),
|
||||
"parseFloat": HostFn("parseFloat", lambda *a: float(a[0]) if a else 0.0),
|
||||
"encodeURIComponent": HostFn("encodeURIComponent", lambda *a: a[0] if a else ""),
|
||||
"decodeURIComponent": HostFn("decodeURIComponent", lambda *a: a[0] if a else ""),
|
||||
"atob": HostFn("atob", lambda *a: a[0] if a else ""),
|
||||
"btoa": HostFn("btoa", lambda *a: a[0] if a else ""),
|
||||
"undefined": JS_UNDEF,
|
||||
"NaN": float("nan"),
|
||||
})
|
||||
return win
|
||||
|
||||
|
||||
def getprop(base, key):
|
||||
"""JS 的 base[key] (非严格语义: 对原始值取属性返回 undefined)"""
|
||||
if isinstance(base, (Stub, JArr)):
|
||||
return base[key]
|
||||
if isinstance(base, dict):
|
||||
return base.get(str(key), JS_UNDEF)
|
||||
if isinstance(base, list):
|
||||
return base[int(key)] if isinstance(key, (int, str)) and str(key).lstrip("-").isdigit() and 0 <= int(key) < len(base) else JS_UNDEF
|
||||
if isinstance(base, str):
|
||||
return base[int(key)] if isinstance(key, (int, str)) and str(key).lstrip("-").isdigit() and 0 <= int(key) < len(base) else JS_UNDEF
|
||||
return JS_UNDEF # number/bool/null/undefined: 属性访问为 undefined
|
||||
|
||||
|
||||
def setprop(base, key, val):
|
||||
"""JS 的 base[key] = val (非严格语义: 对原始值赋值静默忽略)"""
|
||||
if isinstance(base, Stub):
|
||||
return
|
||||
if isinstance(base, JArr):
|
||||
base[key] = val
|
||||
return
|
||||
if isinstance(base, dict):
|
||||
base[str(key)] = val
|
||||
return
|
||||
if isinstance(base, list):
|
||||
i = int(key)
|
||||
if 0 <= i < len(base):
|
||||
base[i] = val
|
||||
else:
|
||||
base.append(val)
|
||||
return
|
||||
return # 原始值: 静默忽略
|
||||
|
||||
|
||||
def as_int(v):
|
||||
if isinstance(v, bool):
|
||||
return 1 if v else 0
|
||||
if v is JS_UNDEF or v is None:
|
||||
return 0
|
||||
if isinstance(v, (int, float)):
|
||||
return int(v) & 0xFFFFFFFF
|
||||
if isinstance(v, str):
|
||||
return len(v) and (ord(v[0]) & 0xFFFFFFFF) or 0
|
||||
return 0
|
||||
|
||||
|
||||
def truthy(v):
|
||||
if v is JS_UNDEF or v is None or v is False:
|
||||
return False
|
||||
if isinstance(v, (int, float)):
|
||||
return v != 0
|
||||
if isinstance(v, str):
|
||||
return len(v) > 0
|
||||
return True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
class VM:
|
||||
def __init__(self, z: List[int], c, max_steps: int = 20_000_000, fresh: bool = True):
|
||||
self.z = z
|
||||
self.c = c
|
||||
self.fresh = fresh # True=最外层调用(E 未传入), False=闭包调用
|
||||
# 与 VM 源码默认一致: E = [[this],[{}]]; 顶层是普通调用 -> this = 全局对象 (c)
|
||||
self.E: List[Any] = [JArr([c]), JArr([JArr()])]
|
||||
self.B: List[Any] = [] # try 栈
|
||||
self.w = None # 异常标志
|
||||
self.H = 0
|
||||
self.steps = 0
|
||||
self.max_steps = max_steps
|
||||
self.done = False
|
||||
self.ret = None
|
||||
self.key_hits: List[Any] = []
|
||||
self.trace = bool(os.environ.get("TRACE"))
|
||||
self.trace_full = bool(os.environ.get("TRACE_FULL"))
|
||||
self.pcs: List[int] = [] # 全量 PC 轨迹 (TRACE_FULL 时记录)
|
||||
self.events: List[Any] = [] # 记录调用/闭包/返回等关键事件
|
||||
|
||||
# ---- 工具
|
||||
def pu(self):
|
||||
return self.E.pop()
|
||||
|
||||
def look(self):
|
||||
self.record_cell(self.E[-1])
|
||||
|
||||
def record_cell(self, v):
|
||||
if isinstance(v, list) and len(v) == 4 and all(isinstance(x, int) for x in v):
|
||||
if all(32 <= (x & 0xFF) < 127 and 32 <= ((x >> 24) & 0xFF) < 127 for x in v):
|
||||
packed = b"".join(x.to_bytes(4, "big") for x in v)
|
||||
if all(32 <= b < 127 for b in packed):
|
||||
self.key_hits.append(list(v))
|
||||
|
||||
# ---- 主循环
|
||||
def run(self):
|
||||
z = self.z
|
||||
while not self.done and self.H < len(z):
|
||||
self.steps += 1
|
||||
if self.steps > self.max_steps:
|
||||
raise RuntimeError("step limit exceeded")
|
||||
op = z[self.H]; self.H += 1
|
||||
if self.trace_full:
|
||||
self.pcs.append(self.H - 1)
|
||||
if self.trace and op in (1, 6, 19, 34, 41, 49, 57, 61):
|
||||
self.events.append((self.H - 1, op))
|
||||
h = self.OP.get(op)
|
||||
if h is None:
|
||||
raise RuntimeError(f"unknown opcode {op} at pc {self.H-1}")
|
||||
r = h()
|
||||
if os.environ.get("DBGCALL") and self.E and self.E[-1] is None:
|
||||
print(f"[push None @pc={self.H-1}] op={op} steps={self.steps}", file=sys.stderr)
|
||||
if os.environ.get("DBGCALL") and self.E and isinstance(self.E[-1], list) and len(self.E[-1]) == 2 and self.E[-1][0] is None:
|
||||
print(f"[产生 [None,{self.E[-1][1]!r}] @pc={self.H-1}] op={op} steps={self.steps}", file=sys.stderr)
|
||||
if r is True:
|
||||
self.done = True
|
||||
return self._finish()
|
||||
|
||||
def _finish(self):
|
||||
"""复刻 VM 的返回语义 (源码):
|
||||
`return A ? (E.pop(), E.slice(3 + VM.v)) : E.pop()`
|
||||
A = !E (E 未传入 => 最外层调用); 闭包调用时 E 已传入 => 返回**栈顶** E.pop()。
|
||||
"""
|
||||
E = self.E
|
||||
if not E:
|
||||
return JS_UNDEF
|
||||
if self.fresh:
|
||||
E.pop()
|
||||
start = 3 + VM_VERSION
|
||||
return JArr(E[start:]) if len(E) > start else JArr()
|
||||
return E.pop()
|
||||
|
||||
# ------------------------------------------------------------------ ops
|
||||
def _run(self):
|
||||
z, E = self.z, self.E
|
||||
OP = {}
|
||||
|
||||
def op(n):
|
||||
def deco(f):
|
||||
OP[n] = f
|
||||
return f
|
||||
return deco
|
||||
|
||||
@op(0) # PUSH_STR_CHAR
|
||||
def _0():
|
||||
E[-1] = (E[-1] if isinstance(E[-1], str) else "") + chr(z[self.H]); self.H += 1
|
||||
|
||||
@op(1) # IS_EXCEPTION -> 结束
|
||||
def _1():
|
||||
if truthy(self.w):
|
||||
self.ret = self.w
|
||||
return True
|
||||
return None
|
||||
|
||||
@op(2)
|
||||
def _2():
|
||||
E.append(E[-1])
|
||||
|
||||
def binop(f):
|
||||
def g():
|
||||
b = E.pop(); E[-2] = f(E[-2], b)
|
||||
return g
|
||||
|
||||
OP[3] = binop(lambda a, b: as_int(a) ^ as_int(b))
|
||||
OP[4] = binop(lambda a, b: a is b or a == b)
|
||||
OP[5] = binop(lambda a, b: (as_int(a) % as_int(b)) if as_int(b) else float("nan"))
|
||||
OP[13] = binop(lambda a, b: a == b)
|
||||
OP[15] = binop(lambda a, b: as_int(a) > as_int(b))
|
||||
OP[20] = binop(lambda a, b: as_int(a) >= as_int(b))
|
||||
OP[23] = binop(lambda a, b: (as_int(a) * as_int(b)) & 0xFFFFFFFF)
|
||||
OP[30] = binop(lambda a, b: as_int(a) & as_int(b))
|
||||
OP[33] = binop(lambda a, b: (as_int(a) - as_int(b)) & 0xFFFFFFFF)
|
||||
OP[35] = binop(lambda a, b: as_int(a) >> (as_int(b) & 31))
|
||||
OP[36] = binop(lambda a, b: (as_int(a) << (as_int(b) & 31)) & 0xFFFFFFFF)
|
||||
OP[39] = binop(lambda a, b: ((as_int(a) + as_int(b)) & 0xFFFFFFFF) if (isinstance(a, int) and isinstance(b, int)) else (a + b))
|
||||
OP[52] = binop(lambda a, b: (as_int(a) / as_int(b)) if as_int(b) else float("nan"))
|
||||
OP[62] = binop(lambda a, b: as_int(a) | as_int(b))
|
||||
OP[65] = binop(lambda a, b: (as_int(a) & 0xFFFFFFFF) >> (as_int(b) & 31))
|
||||
OP[18] = binop(lambda a, b: isinstance(b, (list, dict, JArr)) and (a in (b._ext if isinstance(b, JArr) else b)))
|
||||
|
||||
@op(6) # CALL_FUNC n
|
||||
def _6():
|
||||
n = z[self.H]; self.H += 1
|
||||
args = E[-n:] if n else []
|
||||
del E[len(E) - n:]
|
||||
fn = E.pop()
|
||||
if os.environ.get("DBGCALL"):
|
||||
print(f"[CALL_FUNC @{self.H-2}] n={n} callee={type(fn).__name__} {str(fn)[:70]}", file=sys.stderr)
|
||||
E.append(self.call(fn, args, self.c)) # 真实语义: fn.apply(c, B) -> this = c
|
||||
|
||||
@op(8) # GET_GLOBAL_PROP
|
||||
def _8():
|
||||
E[-1] = getprop(self.c, E[-1])
|
||||
|
||||
@op(9) # MAKE_PROP_REF
|
||||
def _9():
|
||||
a = E.pop(); b = E.pop()
|
||||
E.append([getprop(b[0], b[1]), a])
|
||||
|
||||
@op(10)
|
||||
def _10():
|
||||
E[-1] = type(E[-1]).__name__
|
||||
|
||||
@op(11)
|
||||
def _11():
|
||||
a = E.pop(); E.append(True)
|
||||
|
||||
@op(12)
|
||||
def _12():
|
||||
E[-1] = not truthy(E[-1])
|
||||
|
||||
@op(14) # SET_PROP
|
||||
def _14():
|
||||
a = E[-2]; setprop(a[0], a[1], E[-1])
|
||||
|
||||
@op(17) # SWAP n
|
||||
def _17():
|
||||
n = z[self.H]; self.H += 1
|
||||
i = len(E) - 2 - n
|
||||
b = E[i]; E[i] = E.pop(); E.append(b)
|
||||
|
||||
@op(19) # RET_TRUE
|
||||
def _19():
|
||||
self.ret = True
|
||||
return True
|
||||
|
||||
@op(22) # PUSH_IMM
|
||||
def _22():
|
||||
E.append(z[self.H]); self.H += 1
|
||||
|
||||
@op(25) # LOAD_REG_INDIRECT
|
||||
def _25():
|
||||
cell = E[E.pop()[0]]
|
||||
E.append(cell[0] if cell else JS_UNDEF)
|
||||
|
||||
@op(26) # ITER_NEXT
|
||||
def _26():
|
||||
it = E[-1]
|
||||
if it:
|
||||
E.append(it.pop(0)); E.append(True)
|
||||
else:
|
||||
E.append(JS_UNDEF); E.append(False)
|
||||
|
||||
@op(28)
|
||||
def _28():
|
||||
E.append([self.c, E.pop()])
|
||||
|
||||
@op(29)
|
||||
def _29():
|
||||
if os.environ.get("DBGCALL"):
|
||||
print(f"[PUSH_NULL @pc={self.H-1}] steps={self.steps}", file=sys.stderr)
|
||||
E.append(None)
|
||||
|
||||
@op(31) # PUSH_IMM_ARRAY
|
||||
def _31():
|
||||
E.append(JArr([z[self.H]])); self.H += 1
|
||||
|
||||
@op(32) # MAKE_REG_PROP_REF
|
||||
def _32():
|
||||
a = E.pop()
|
||||
idx = E.pop()
|
||||
cell = E[idx[0]] if isinstance(idx, list) else E[idx]
|
||||
val = cell[0] if isinstance(cell, list) and cell else JS_UNDEF
|
||||
if os.environ.get("DBGCALL") and (val is None or val is JS_UNDEF):
|
||||
print(f"[MAKE_REG_PROP_REF @{self.H-1}] reg={idx[0] if isinstance(idx,list) else idx} val={val!r} cell={str(cell)[:40]}", file=sys.stderr)
|
||||
E.append([val, a])
|
||||
|
||||
@op(34) # CALL_METHOD n
|
||||
def _34():
|
||||
n = z[self.H]; self.H += 1
|
||||
args = E[-n:] if n else []
|
||||
del E[len(E) - n:]
|
||||
ref = E.pop()
|
||||
if os.environ.get("DBGCALL"):
|
||||
callee = getprop(ref[0], ref[1])
|
||||
print(f"[CALL_METHOD @{self.H-2}] n={n} ref={str(ref)[:90]} arity/type={type(ref).__name__}", file=sys.stderr)
|
||||
print(f" stack tail: {[str(x)[:36] for x in E[-6:]]}", file=sys.stderr)
|
||||
E.append(self.call(getprop(ref[0], ref[1]), args, ref[0]))
|
||||
|
||||
@op(37) # JUMP_IF_TRUE
|
||||
def _37():
|
||||
t = z[self.H]; self.H += 1
|
||||
if truthy(E[-1]):
|
||||
self.H = t
|
||||
|
||||
@op(38) # FOR_IN_KEYS
|
||||
def _38():
|
||||
o = E.pop()
|
||||
E.append(JArr(list(o._ext.keys()) if isinstance(o, JArr) else list(o.keys()) if isinstance(o, dict) else []))
|
||||
|
||||
@op(41) # NEW_METHOD n
|
||||
def _41():
|
||||
n = z[self.H]; self.H += 1
|
||||
args = E[-n:] if n else []
|
||||
del E[len(E) - n:]
|
||||
args.insert(0, None)
|
||||
fn = E.pop()
|
||||
E.append(self.new(fn, args))
|
||||
|
||||
@op(42)
|
||||
def _42():
|
||||
E.append(JS_UNDEF)
|
||||
|
||||
@op(45)
|
||||
def _45():
|
||||
E.append(False)
|
||||
|
||||
@op(46) # STORE_REG
|
||||
def _46():
|
||||
idx = E[-2][0]
|
||||
cell = E[idx]
|
||||
if not isinstance(cell, list):
|
||||
cell = []
|
||||
E[idx] = cell
|
||||
v = E[-1]
|
||||
if cell:
|
||||
cell[0] = v
|
||||
else:
|
||||
cell.append(v)
|
||||
self.record_cell(v)
|
||||
|
||||
@op(47) # ALLOC_REG
|
||||
def _47():
|
||||
a = z[self.H]; self.H += 1
|
||||
while len(E) <= a:
|
||||
E.append(JS_UNDEF)
|
||||
if not isinstance(E[a], list):
|
||||
E[a] = []
|
||||
|
||||
@op(48)
|
||||
def _48():
|
||||
self.B.pop()
|
||||
|
||||
@op(49) # THROW
|
||||
def _49():
|
||||
raise RuntimeError("VM throw")
|
||||
|
||||
@op(51)
|
||||
def _51():
|
||||
E.pop()
|
||||
|
||||
@op(53) # SET_STACK_LEN
|
||||
def _53():
|
||||
n = z[self.H]; self.H += 1
|
||||
while len(E) < n:
|
||||
E.append(JS_UNDEF)
|
||||
del E[n:]
|
||||
|
||||
@op(54)
|
||||
def _54():
|
||||
a = E.pop(); b = E.pop()
|
||||
E.append(JArr([a, b]))
|
||||
|
||||
@op(55)
|
||||
def _55():
|
||||
E.append("")
|
||||
|
||||
@op(56) # PUSH_TRY
|
||||
def _56():
|
||||
self.H += 2
|
||||
|
||||
@op(57) # CREATE_CLOSURE
|
||||
def _57():
|
||||
target = z[self.H]; self.H += 1
|
||||
cap = z[self.H]; self.H += 1
|
||||
par = z[self.H]; self.H += 1
|
||||
caps = []
|
||||
for _ in range(cap):
|
||||
q = z[self.H]; self.H += 1
|
||||
r = z[self.H]; self.H += 1
|
||||
caps.append((q, r))
|
||||
params = []
|
||||
for _ in range(par):
|
||||
params.append(z[self.H]); self.H += 1
|
||||
E.append(("closure", target, caps, params))
|
||||
|
||||
@op(59) # GET_PROP
|
||||
def _59():
|
||||
a = E.pop()
|
||||
E.append(getprop(a[0], a[1]))
|
||||
|
||||
@op(60) # JUMP
|
||||
def _60():
|
||||
self.H = z[self.H]
|
||||
|
||||
@op(61) # NEW_FUNC n
|
||||
def _61():
|
||||
n = z[self.H]; self.H += 1
|
||||
args = E[-n:] if n else []
|
||||
del E[len(E) - n:]
|
||||
args.insert(0, None)
|
||||
fn = E.pop()
|
||||
E.append(self.new(fn, args))
|
||||
|
||||
@op(64) # REPLACE_TOP_IMM
|
||||
def _64():
|
||||
E[-1] = z[self.H]; self.H += 1
|
||||
|
||||
@op(66)
|
||||
def _66():
|
||||
E.append(True)
|
||||
|
||||
@op(68) # LOAD_REG_IMM
|
||||
def _68():
|
||||
a = z[self.H]; self.H += 1
|
||||
cell = E[a] if a < len(E) else None
|
||||
E.append(cell[0] if isinstance(cell, list) and cell else JS_UNDEF)
|
||||
|
||||
@op(69)
|
||||
def _69():
|
||||
self.w = None
|
||||
|
||||
self.OP = OP
|
||||
return self.run()
|
||||
|
||||
# ---- 调用/构造
|
||||
def call(self, fn, args, this):
|
||||
if isinstance(fn, tuple) and fn and fn[0] == "closure":
|
||||
sub = VM(self.z, self.c, self.max_steps - self.steps, fresh=False)
|
||||
sub.key_hits = self.key_hits # 子 VM 共享命中列表
|
||||
sub.pcs = self.pcs # 共享 PC 轨迹 (轨迹是全局的)
|
||||
sub.events = self.events # 共享关键事件
|
||||
sub.trace = self.trace
|
||||
sub.trace_full = self.trace_full
|
||||
if os.environ.get("DBGCALL"):
|
||||
print(f"[invoke closure target=@{fn[1]}] caps={fn[2]} params={fn[3]} args={[str(a)[:20] for a in args]}", file=sys.stderr)
|
||||
for (q, r) in fn[2]:
|
||||
src = self.E[r] if r < len(self.E) else "<oob>"
|
||||
print(f" cap r{q} <- parent r{r} = {str(src)[:50]}", file=sys.stderr)
|
||||
sub.E = [JArr() for _ in range(32)]
|
||||
sub.E[0] = JArr([this]); sub.E[1] = JArr([JArr(args)]); sub.E[2] = JArr([fn])
|
||||
for (q, r) in fn[2]:
|
||||
sub.E[q] = self.E[r]
|
||||
for i, r in enumerate(fn[3]):
|
||||
sub.E[r] = JArr([args[i] if i < len(args) else JS_UNDEF])
|
||||
sub.H = fn[1]
|
||||
try:
|
||||
v = sub._run()
|
||||
self.steps += sub.steps
|
||||
return v
|
||||
except Exception as exc:
|
||||
if os.environ.get("DBGCALL"):
|
||||
import traceback
|
||||
print(f"[closure @{fn[1]} 执行异常] {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
traceback.print_exc(limit=3)
|
||||
return JS_UNDEF
|
||||
if isinstance(fn, HostFn):
|
||||
return fn(*args)
|
||||
if callable(fn) and not isinstance(fn, (JArr, Stub, tuple)):
|
||||
try:
|
||||
return fn(*args)
|
||||
except Exception:
|
||||
return JS_UNDEF
|
||||
if isinstance(fn, Stub):
|
||||
return Stub("call")
|
||||
return JS_UNDEF
|
||||
|
||||
def new(self, fn, args):
|
||||
return self.call(fn, args, {})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "tcaptcha/core_js/tdc_sample.js"
|
||||
js = open(path, encoding="utf-8", errors="replace").read()
|
||||
z = extract_bytecode(js)
|
||||
known = os.environ.get("KEY")
|
||||
known_key = [int(x) for x in known.split(",")] if known else None
|
||||
print(f"bytecode={len(z)} known_key={known_key}")
|
||||
|
||||
vm = VM(z, make_host(), max_steps=30_000_000)
|
||||
try:
|
||||
vm._run()
|
||||
except Exception as e:
|
||||
print("VM halted with:", type(e).__name__, str(e)[:200])
|
||||
print("steps:", vm.steps, "done:", vm.done, "ret:", str(vm.ret)[:80])
|
||||
names = {1: "IS_EXCEPTION", 6: "CALL_FUNC", 19: "RET_TRUE", 34: "CALL_METHOD",
|
||||
41: "NEW_METHOD", 49: "THROW", 57: "CREATE_CLOSURE", 61: "NEW_FUNC"}
|
||||
print("关键事件 (pc, op):")
|
||||
for pc, op in vm.events[:40]:
|
||||
print(f" @{pc:05d} {names.get(op, op)}")
|
||||
print(f" ... 共 {len(vm.events)} 条")
|
||||
|
||||
# tdc.js 顶层只"注册"模块工厂; 这里把寄存器/数组里保存的闭包全收集出来 = 模块表
|
||||
def collect_closures(obj, out, seen):
|
||||
oid = id(obj)
|
||||
if oid in seen:
|
||||
return
|
||||
seen.add(oid)
|
||||
if isinstance(obj, tuple) and obj and obj[0] == "closure":
|
||||
out.append(obj)
|
||||
return
|
||||
if isinstance(obj, Stub):
|
||||
for v in obj._props.values():
|
||||
collect_closures(v, out, seen)
|
||||
elif isinstance(obj, (JArr, list)):
|
||||
for v in list(obj):
|
||||
collect_closures(v, out, seen)
|
||||
if isinstance(obj, JArr):
|
||||
for v in obj._ext.values():
|
||||
collect_closures(v, out, seen)
|
||||
elif isinstance(obj, dict):
|
||||
for v in obj.values():
|
||||
collect_closures(v, out, seen)
|
||||
|
||||
closures: List[Any] = []
|
||||
collect_closures([vm.E, vm.c], closures, set())
|
||||
print("模块表: 收集到闭包", len(closures), "个 -> target:", [c[1] for c in closures][:80])
|
||||
for i, cl in enumerate(closures):
|
||||
try:
|
||||
vm.call(cl, [Stub("arg")], {})
|
||||
except Exception as e:
|
||||
print(f" closure#{i} (target=@{cl[1]}) raised {type(e).__name__}: {str(e)[:80]}")
|
||||
print("total steps after invoking closures:", vm.steps)
|
||||
hits = [h for h in vm.key_hits if known_key is None or h == known_key]
|
||||
print("candidate 4-char packed arrays:", len(hits))
|
||||
for h in hits[:10]:
|
||||
print(" ", h, "->", b"".join(x.to_bytes(4, "big") for x in h))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Webpack Bundle Unpacker & Module Extractor
|
||||
* -------------------------------------------
|
||||
* 依据 AGENTS.md §2.2 L1/L2 规范,将 Webpack 单文件打包解包为独立可读的模块文件。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const parser = require("@babel/parser");
|
||||
const generate = require("@babel/generator").default;
|
||||
|
||||
function unpackBundle(bundlePath, outputDir) {
|
||||
const code = fs.readFileSync(bundlePath, "utf-8");
|
||||
console.log(`Parsing bundle ${bundlePath} (${code.length} bytes)...`);
|
||||
const ast = parser.parse(code, { sourceType: "script" });
|
||||
|
||||
const call = ast.program.body[0].expression.argument;
|
||||
const modulesArray = call.arguments[0].elements;
|
||||
console.log(`Found ${modulesArray.length} modules.`);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const index = [];
|
||||
|
||||
modulesArray.forEach((modNode, idx) => {
|
||||
if (!modNode) {
|
||||
index.push({ id: idx, empty: true });
|
||||
return;
|
||||
}
|
||||
const modCode = generate(modNode).code;
|
||||
const modFile = path.join(outputDir, `module_${idx.toString().padStart(3, "0")}.js`);
|
||||
fs.writeFileSync(modFile, modCode, "utf-8");
|
||||
|
||||
// Search keywords in module to create index
|
||||
const keywords = [];
|
||||
if (/verify/i.test(modCode)) keywords.push("verify");
|
||||
if (/pow/i.test(modCode)) keywords.push("pow");
|
||||
if (/tdc/i.test(modCode)) keywords.push("tdc");
|
||||
if (/collect/i.test(modCode)) keywords.push("collect");
|
||||
if (/getsig/i.test(modCode)) keywords.push("getsig");
|
||||
if (/prehandle/i.test(modCode)) keywords.push("prehandle");
|
||||
if (/subcapclass/i.test(modCode)) keywords.push("subcapclass");
|
||||
if (/ticket/i.test(modCode)) keywords.push("ticket");
|
||||
|
||||
index.push({
|
||||
id: idx,
|
||||
size: modCode.length,
|
||||
file: `module_${idx.toString().padStart(3, "0")}.js`,
|
||||
keywords,
|
||||
});
|
||||
});
|
||||
|
||||
const indexFile = path.join(outputDir, "manifest.json");
|
||||
fs.writeFileSync(indexFile, JSON.stringify(index, null, 2), "utf-8");
|
||||
console.log(`Manifest written to ${indexFile}`);
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const bundle = process.argv[2] || "tcaptcha/core_js/tcaptcha-frame.ba387dd1.js";
|
||||
const outDir = process.argv[3] || "tcaptcha/core_js/frame_modules";
|
||||
unpackBundle(bundle, outDir);
|
||||
}
|
||||
|
||||
module.exports = { unpackBundle };
|
||||
Reference in New Issue
Block a user