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:
+149
-28
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user