Files
hello_js_reverse_skill/tcaptcha/tdc_worker.js
T
yml2213 fbcf2d448a feat(反VM): 纯 Python 还原 XTEA 算法 + 从 tdc.js 运行时提取每构建密钥
做了什么
- 确认 collect 的加密算法就是教科书 XTEA (32 轮, delta=0x9E3779B9),
  并证明 crypto.py 原有的"带加盐"写法只是把两个密钥字折叠:
  K' = [k0, k1+salt1, k2+salt2, k3]
- tdc_worker.js 新增 captureKey 模式: 正则改写 VM 的 ADD/XOR opcode 为带日志版本,
  用反向引用自动识别栈变量名 (样本=g/雨云实时=j/1ms=M, 版本无关);
  匹配数不为 1 时显式报错, 不静默失败
- 密钥反推: (sum + K[j]) 是一次 ADD, 两操作数必有一个等于 r*DELTA,
  另一个就是密钥字; 再对 Top-N 候选做 4! 排列 + 明文内容特征验证
- 新增 recover_payload_template / generate_collect_from_template:
  运行时标定一次明文模板, 之后轨迹生成/组包/加密/编码全部在 Python
- 新增 tcaptcha/proxy.py 代理提取, client 支持 --proxy-api 遇 12 自动换 IP

为什么
- 服务端只认 collect 的解密结果, 而密钥是每构建随机的;
  把算法做成纯 Python 后, 只有"取密钥"依赖 Node, 抗发版能力最强

怎么验证的
- 纯 Python XTEA 加密结果与真实 tdc.js 产物前 48 字节逐字节一致
- 密钥按构建固定的证据: 同一脚本 3 次运行密文首块均为 f6a24e3e4fb5dd2b
- 三份脚本 (样本/雨云实时/1ms实时) 均成功复原密钥
- 雨云 aid 端到端: 纯 Python 出包 -> errorCode=0 + 真实 ticket
- 回归测试: tcaptcha/tests/test_xtea_key.py 11 项断言全过,
  文字点选求解 8/8; 新增 tcaptcha/selftest.py 一键复验

已知未解
- 1ms aid=195672344 在答案正确时仍返回 errorCode=12; 已排除密钥、
  遥测引擎、出口 IP (4 个住宅代理)、短时频控、静默窗口
2026-10-11 18:07:09 +08:00

454 lines
17 KiB
JavaScript

/**
* Tencent TDC.js Sandboxed Worker & Mouse Trajectory Generator
* Runs TDC.js in an isolated mock DOM environment to generate authentic
* encrypted telemetry (`collect`) and session encryption key (`eks`).
*/
const fs = require('fs');
const vm = require('vm');
class Element {
constructor(tagName) {
this.tagName = (tagName || 'div').toUpperCase();
this.children = [];
this.style = {};
this.attributes = {};
}
appendChild(child) {
this.children.push(child);
return child;
}
removeChild(child) {
const idx = this.children.indexOf(child);
if (idx !== -1) this.children.splice(idx, 1);
return child;
}
insertBefore(newChild, refChild) {
const idx = this.children.indexOf(refChild);
if (idx !== -1) this.children.splice(idx, 0, newChild);
else this.children.push(newChild);
return newChild;
}
cloneNode(deep) {
const el = new Element(this.tagName);
el.style = { ...this.style };
return el;
}
setAttribute(k, v) { this.attributes[k] = String(v); }
getAttribute(k) { return this.attributes[k] || null; }
hasAttribute(k) { return k in this.attributes; }
removeAttribute(k) { delete this.attributes[k]; }
getBoundingClientRect() {
return { top: 0, left: 0, width: 360, height: 360, bottom: 360, right: 360 };
}
}
class CanvasElement extends Element {
constructor() {
super('CANVAS');
this.width = 300;
this.height = 150;
}
getContext(type) {
if (type.includes('webgl')) {
return {
getSupportedExtensions: () => [
'ANGLE_instanced_arrays', 'EXT_blend_minmax', 'EXT_texture_filter_anisotropic',
'OES_element_index_uint', 'OES_standard_derivatives', 'OES_texture_float',
'OES_texture_half_float', 'OES_vertex_array_object', 'WEBGL_compressed_texture_s3tc',
'WEBGL_debug_renderer_info', 'WEBGL_depth_texture', 'WEBGL_lose_context'
],
getExtension: (ext) => {
if (ext === 'WEBGL_debug_renderer_info') {
return { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 };
}
return null;
},
getParameter: (p) => {
if (p === 37445) return 'Apple';
if (p === 37446) return 'Apple M1';
if (p === 7936) return 'WebKit';
if (p === 7937) return 'WebKit WebGL';
return 0;
},
createBuffer: () => ({}),
bindBuffer: () => {},
bufferData: () => {},
createProgram: () => ({}),
createShader: () => ({}),
shaderSource: () => {},
compileShader: () => {},
attachShader: () => {},
linkProgram: () => {},
useProgram: () => {},
drawArrays: () => {},
viewport: () => {},
clearColor: () => {},
clear: () => {}
};
}
return {
fillRect: () => {},
fillText: () => {},
getImageData: () => ({ data: new Uint8ClampedArray(4) }),
measureText: (txt) => ({ width: txt.length * 10 }),
beginPath: () => {},
arc: () => {},
fill: () => {},
stroke: () => {}
};
}
toDataURL() {
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
}
}
/**
* Generate human-like mouse movement trajectory using cubic Bézier curves and Gaussian tremor.
*/
function generateBezierTrajectory(p0, p1, steps = 25) {
// Control points with natural curve deviation
const dx = p1.x - p0.x;
const dy = p1.y - p0.y;
const dist = Math.hypot(dx, dy);
// Lateral deviation perpendicular to direct vector
const perpX = -dy / (dist || 1);
const perpY = dx / (dist || 1);
const curvature = (Math.random() - 0.5) * Math.min(60, dist * 0.4);
const cp1 = {
x: p0.x + dx * 0.3 + perpX * curvature + (Math.random() - 0.5) * 10,
y: p0.y + dy * 0.3 + perpY * curvature + (Math.random() - 0.5) * 10,
};
const cp2 = {
x: p0.x + dx * 0.7 + perpX * curvature * 0.6 + (Math.random() - 0.5) * 10,
y: p0.y + dy * 0.7 + perpY * curvature * 0.6 + (Math.random() - 0.5) * 10,
};
const points = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
// Ease in-out parameterization
const easeT = t * t * (3 - 2 * t);
const invT = 1 - easeT;
const bx = invT * invT * invT * p0.x +
3 * invT * invT * easeT * cp1.x +
3 * invT * easeT * easeT * cp2.x +
easeT * easeT * easeT * p1.x;
const by = invT * invT * invT * p0.y +
3 * invT * invT * easeT * cp1.y +
3 * invT * easeT * easeT * cp2.y +
easeT * easeT * easeT * p1.y;
// Subtle tremor (except start and end)
const tremor = (i === 0 || i === steps) ? 0 : (Math.random() - 0.5) * 1.5;
points.push({
x: Math.round(bx + tremor),
y: Math.round(by + tremor)
});
}
return points;
}
function runTdcCode(rawCode, clickPoints, options) {
const opt = Object.assign({
aid: '2039519451',
referrer: 'https://app.rainyun.com/',
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',
screenWidth: 1920,
screenHeight: 1080,
availHeight: 1055,
devicePixelRatio: 2,
// 验证码 iframe 在页面中的显示尺寸 (672x480 原图会被缩放到该尺寸)
frameWidth: 360,
frameHeight: 360,
// 原图 -> 显示空间的映射 (默认按 320/672 缩放, 与雨云场景一致)
displayScale: null,
offsetX: 20,
offsetY: 60
}, options || {});
const listeners = [];
function addL(type, fn) {
listeners.push({ type, fn });
}
const docBody = new Element('BODY');
const docElement = new Element('HTML');
const baseWindow = {
location: {
href: `https://turing.captcha.qcloud.com/cap_union_new_show?aid=${opt.aid}`,
protocol: 'https:',
host: 'turing.captcha.qcloud.com',
hostname: 'turing.captcha.qcloud.com',
pathname: '/cap_union_new_show',
search: `?aid=${opt.aid}`,
hash: ''
},
document: {
createElement: (tag) => {
if ((tag || '').toLowerCase() === 'canvas') return new CanvasElement();
return new Element(tag);
},
addEventListener: addL,
removeEventListener: () => {},
body: docBody,
documentElement: docElement,
referrer: opt.referrer,
cookie: '',
getElementsByTagName: (t) => [t === 'body' ? docBody : docElement],
getElementById: () => null,
title: '安全验证',
head: new Element('HEAD')
},
navigator: {
userAgent: opt.userAgent,
platform: opt.platform,
languages: ['zh-CN', 'zh', 'en'],
language: 'zh-CN',
cookieEnabled: true,
plugins: [{ name: 'PDF Viewer' }],
mimeTypes: []
},
screen: {
width: opt.screenWidth,
height: opt.screenHeight,
availWidth: opt.screenWidth,
availHeight: opt.availHeight,
colorDepth: 24,
pixelDepth: 24
},
performance: {
now: () => Date.now(),
timing: {
navigationStart: Date.now() - 2500,
loadEventEnd: Date.now() - 800
}
},
history: { length: 2 },
indexedDB: {},
sessionStorage: { getItem: () => null, setItem: () => {} },
localStorage: { getItem: () => null, setItem: () => {} },
Date: Date,
Math: Math,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
setInterval: setInterval,
clearInterval: clearInterval,
console: { log: () => {}, error: () => {}, warn: () => {} },
innerWidth: opt.frameWidth,
innerHeight: opt.frameHeight,
outerWidth: opt.screenWidth,
outerHeight: opt.screenHeight,
devicePixelRatio: opt.devicePixelRatio,
addEventListener: addL,
removeEventListener: () => {}
};
baseWindow.window = baseWindow;
baseWindow.self = baseWindow;
baseWindow.top = baseWindow;
baseWindow.parent = baseWindow;
// 注意: vm.runInNewContext 每次调用都会新建一个 context, 桩打不进去。
// 必须显式 createContext 一次, 之后 prelude 与 tdc.js 都跑在同一个 context 里。
const vmContext = vm.createContext(baseWindow);
let instrumentedCode = rawCode;
if (opt.captureKey) {
// 密钥提取: 把 VM 的 XOR / ADD 两个 opcode 换成带日志的版本。
// 自定义 XTEA 每轮都要算 f(v) ^ (sum + K[j]), 其中 (sum + K[j]) 是 XOR 的操作数,
// 记下它就能反推 K[j] = (operand - round*DELTA) mod 2^32。
// 用 Array.prototype.push 打桩是抓不到的 —— 这些 opcode 用的是
// `g[g.length-2]=...` 直接下标赋值。
vm.runInContext(
`globalThis.__TDC_XOR=[];globalThis.__TDC_ADD=[];` +
`globalThis.__TDC_LOGX=function(a,b){var s=globalThis.__TDC_XOR;` +
`if(s.length<4000000){s.push(a>>>0,b>>>0);}return (a^b);};` +
`globalThis.__TDC_LOGA=function(a,b){var s=globalThis.__TDC_ADD;` +
`if(s.length<4000000){s.push(a>>>0,b>>>0);}return (a+b);};`,
vmContext);
// 注意: opcode 表里的 **栈变量名每份构建都不同**
// (实测: 样本=g, 雨云实时=j, 1ms实时=M —— 见 AGENTS.md 附录 A#6 码表轮换),
// 所以必须用反向引用自动识别变量名, 不能写死 'g'。
// 目标 opcode 形态: <V>[<V>.length-2]=<V>[<V>.length-2] <op> <V>.pop()
const mkRe = op => new RegExp(
'(\\w+)\\[(\\1\\.length-2)\\]=\\1\\[\\2\\]\\' + op + '\\1\\.pop\\(\\)', 'g');
const xorRe = mkRe('^');
const addRe = mkRe('+');
const nXor = (instrumentedCode.match(xorRe) || []).length;
const nAdd = (instrumentedCode.match(addRe) || []).length;
if (nXor !== 1 || nAdd !== 1) {
// 不静默失败 (AGENTS.md 附录 A#8): 结构性变化必须显式暴露
return {
error: `opcode 插桩失败: 期望 XOR/ADD 各 1 处, 实际 XOR=${nXor} ADD=${nAdd}. ` +
`tdc.js 的 VM opcode 形态可能已变更, 需要更新插桩规则。`
};
}
instrumentedCode = instrumentedCode
.replace(addRe, (m, v, idx) => `${v}[${idx}]=__TDC_LOGA(${v}[${idx}],${v}.pop())`)
.replace(xorRe, (m, v, idx) => `${v}[${idx}]=__TDC_LOGX(${v}[${idx}],${v}.pop())`);
if (opt.captureKeyVerbose) {
process.stderr.write(`[captureKey] 插桩 XOR opcode x${nXor}, ADD opcode x${nAdd}\n`);
}
}
// Execute TDC.js
vm.runInContext(instrumentedCode, vmContext);
// Map 672x480 coordinates to 360x360 iframe display space
// 原图坐标 (672x480) -> 验证码 iframe 显示空间
const scale = opt.displayScale !== null && opt.displayScale !== undefined
? opt.displayScale : (320.0 / 672.0);
const targetPoints = clickPoints.map(p => ({
x: Math.round(opt.offsetX + p[0] * scale),
y: Math.round(opt.offsetY + p[1] * scale)
}));
// Generate path: entrance -> P1 -> P2 -> P3
let currentPoint = {
x: Math.round(opt.frameWidth * 0.42 + (Math.random() - 0.5) * 60),
y: Math.round(opt.frameHeight * 0.89 + (Math.random() - 0.5) * 20)
};
const mmListener = listeners.find(l => l.type === 'mousemove');
let currentTime = Date.now() - 1500;
if (mmListener) {
for (const target of targetPoints) {
// Leg trajectory
const steps = Math.floor(18 + Math.random() * 12);
const leg = generateBezierTrajectory(currentPoint, target, steps);
for (const pt of leg) {
currentTime += Math.floor(12 + Math.random() * 16);
mmListener.fn({
clientX: pt.x,
clientY: pt.y,
pageX: pt.x,
pageY: pt.y,
screenX: pt.x + 300,
screenY: pt.y + 200,
timeStamp: currentTime,
type: 'mousemove',
isTrusted: true
});
}
currentPoint = target;
// Dwell pause on icon (150ms ~ 350ms)
const dwellSteps = Math.floor(4 + Math.random() * 5);
for (let i = 0; i < dwellSteps; i++) {
currentTime += Math.floor(25 + Math.random() * 30);
mmListener.fn({
clientX: currentPoint.x + Math.round((Math.random() - 0.5) * 2),
clientY: currentPoint.y + Math.round((Math.random() - 0.5) * 2),
pageX: currentPoint.x,
pageY: currentPoint.y,
screenX: currentPoint.x + 300,
screenY: currentPoint.y + 200,
timeStamp: currentTime,
type: 'mousemove',
isTrusted: true
});
}
}
}
const collect = baseWindow.TDC.getData();
const info = baseWindow.TDC.getInfo();
const eks = info ? info.info : '';
const result = {
collect,
tlg: collect ? collect.length : 0,
eks
};
// 从记录的 ADD 操作数里反推 XTEA 密钥 (含索引)
//
// 字节码里 (sum + K[j]) 是一次 ADD, 两个操作数分别是
// sum = (r*DELTA) mod 2^32 与 K[j] 本身。
// 反过来查表: 找到 r 后, j 只可能是 r&3 (v0 分支) 或 ((r+1)*DELTA>>11)&3 (v1 分支)。
// 对同一个 K 的多次观测取交集, 就能唯一确定它的索引 —— 不需要 4! 排列搜索。
if (opt.captureKey) {
const DELTA = 2654435769;
const sumToR = new Map();
for (let r = 0; r <= 32; r++) sumToR.set(Math.imul(r, DELTA) >>> 0, r);
const idxOf = r => [r & 3, (((r + 1) * DELTA) >>> 0 >>> 11) & 3];
const addLog = baseWindow.__TDC_ADD || [];
const obs = new Map(); // K -> Map(r -> count)
for (let i = 0; i + 1 < addLog.length; i += 2) {
const a = addLog[i], b = addLog[i + 1];
let r = sumToR.get(a), k = b;
if (r === undefined) { r = sumToR.get(b); k = a; }
if (r === undefined || k === 0 || (k >>> 0) === DELTA) continue;
k = k >>> 0;
if (!obs.has(k)) obs.set(k, new Map());
const m = obs.get(k);
m.set(r, (m.get(r) || 0) + 1);
}
const entries = [];
for (const [k, rmap] of obs) {
let total = 0, candidates = null;
for (const [r, c] of rmap) {
total += c;
const s = new Set(idxOf(r));
candidates = candidates === null ? s : new Set([...candidates].filter(x => s.has(x)));
}
if (candidates && candidates.size === 1) {
entries.push([k, total, [...candidates][0], rmap.size]);
} else {
entries.push([k, total, candidates && candidates.size ? [...candidates] : null, rmap.size]);
}
}
entries.sort((x, y) => y[1] - x[1]);
result.key_words = entries.slice(0, 24);
result.add_operand_count = addLog.length;
}
return result;
}
function runTdc(tdcFilePath, clickPoints, options) {
const rawCode = fs.readFileSync(tdcFilePath, 'utf8');
return runTdcCode(rawCode, clickPoints, options);
}
if (require.main === module) {
const tdcPath = process.argv[2];
const pointsArg = process.argv[3];
const optionsArg = process.argv[4];
const points = pointsArg ? JSON.parse(pointsArg) : [[626, 253], [120, 120], [438, 33]];
const options = optionsArg ? JSON.parse(optionsArg) : {};
if (tdcPath === '-') {
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => {
try {
const result = runTdcCode(input, points, options);
console.log(JSON.stringify(result));
} catch (err) {
console.log(JSON.stringify({ error: String(err && err.stack || err) }));
process.exitCode = 1;
}
});
} else {
try {
const result = runTdc(tdcPath, points, options);
console.log(JSON.stringify(result));
} catch (err) {
console.log(JSON.stringify({ error: String(err && err.stack || err) }));
process.exitCode = 1;
}
}
}
module.exports = { runTdc, runTdcCode };