做了什么 - 确认 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 个住宅代理)、短时频控、静默窗口
454 lines
17 KiB
JavaScript
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 };
|
|
|