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 个住宅代理)、短时频控、静默窗口
This commit is contained in:
yml2213
2026-10-11 18:07:09 +08:00
parent 9962eed3e8
commit fbcf2d448a
25 changed files with 9527 additions and 275 deletions
+149 -28
View File
@@ -152,7 +152,25 @@ function generateBezierTrajectory(p0, p1, steps = 25) {
return points;
}
function runTdcCode(rawCode, clickPoints) {
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 });
@@ -163,12 +181,12 @@ function runTdcCode(rawCode, clickPoints) {
const baseWindow = {
location: {
href: 'https://turing.captcha.qcloud.com/cap_union_new_show?aid=2039519451',
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=2039519451',
search: `?aid=${opt.aid}`,
hash: ''
},
document: {
@@ -180,7 +198,7 @@ function runTdcCode(rawCode, clickPoints) {
removeEventListener: () => {},
body: docBody,
documentElement: docElement,
referrer: 'https://app.rainyun.com/',
referrer: opt.referrer,
cookie: '',
getElementsByTagName: (t) => [t === 'body' ? docBody : docElement],
getElementById: () => null,
@@ -188,8 +206,8 @@ function runTdcCode(rawCode, clickPoints) {
head: new Element('HEAD')
},
navigator: {
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36',
platform: 'MacIntel',
userAgent: opt.userAgent,
platform: opt.platform,
languages: ['zh-CN', 'zh', 'en'],
language: 'zh-CN',
cookieEnabled: true,
@@ -197,10 +215,10 @@ function runTdcCode(rawCode, clickPoints) {
mimeTypes: []
},
screen: {
width: 1920,
height: 1080,
availWidth: 1920,
availHeight: 1055,
width: opt.screenWidth,
height: opt.screenHeight,
availWidth: opt.screenWidth,
availHeight: opt.availHeight,
colorDepth: 24,
pixelDepth: 24
},
@@ -222,11 +240,11 @@ function runTdcCode(rawCode, clickPoints) {
setInterval: setInterval,
clearInterval: clearInterval,
console: { log: () => {}, error: () => {}, warn: () => {} },
innerWidth: 360,
innerHeight: 360,
outerWidth: 1920,
outerHeight: 1080,
devicePixelRatio: 2,
innerWidth: opt.frameWidth,
innerHeight: opt.frameHeight,
outerWidth: opt.screenWidth,
outerHeight: opt.screenHeight,
devicePixelRatio: opt.devicePixelRatio,
addEventListener: addL,
removeEventListener: () => {}
};
@@ -235,20 +253,66 @@ function runTdcCode(rawCode, clickPoints) {
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.runInNewContext(rawCode, baseWindow);
vm.runInContext(instrumentedCode, vmContext);
// Map 672x480 coordinates to 360x360 iframe display space
const scale = 320.0 / 672.0;
// 原图坐标 (672x480) -> 验证码 iframe 显示空间
const scale = opt.displayScale !== null && opt.displayScale !== undefined
? opt.displayScale : (320.0 / 672.0);
const targetPoints = clickPoints.map(p => ({
x: Math.round(20 + p[0] * scale),
y: Math.round(60 + p[1] * scale)
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(150 + (Math.random() - 0.5) * 60),
y: Math.round(320 + (Math.random() - 0.5) * 20)
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');
@@ -297,34 +361,91 @@ function runTdcCode(rawCode, clickPoints) {
const info = baseWindow.TDC.getInfo();
const eks = info ? info.info : '';
return {
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) {
function runTdc(tdcFilePath, clickPoints, options) {
const rawCode = fs.readFileSync(tdcFilePath, 'utf8');
return runTdcCode(rawCode, clickPoints);
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', () => {
const result = runTdcCode(input, points);
console.log(JSON.stringify(result));
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 {
const result = runTdc(tdcPath, points);
console.log(JSON.stringify(result));
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;
}
}
}