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:
yml2213
2026-10-12 12:34:57 +08:00
parent 51dff870cf
commit 344eea2194
169 changed files with 599 additions and 83331 deletions
-118
View File
@@ -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); });
-94
View File
@@ -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); });
-178
View File
@@ -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); });
-81
View File
@@ -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); });
-86
View File
@@ -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); });
-77
View File
@@ -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); });
-79
View File
@@ -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); });
-467
View File
@@ -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())
-324
View File
@@ -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 -4
View File
@@ -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):
-144
View File
@@ -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) }));
-345
View File
@@ -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())
-329
View File
@@ -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())
-458
View File
@@ -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())
-389
View File
@@ -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 };
-432
View File
@@ -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())
-109
View File
@@ -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())
-174
View File
@@ -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())
-48
View File
@@ -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));
-27
View File
@@ -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);
-681
View File
@@ -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())
-66
View File
@@ -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 };