Files
hello_js_reverse_skill/tcaptcha/solve_in_browser.js
T
yml2213 1ef819b48d feat(纯算): 以成功样本明文为模板的纯 Python 出包管线 + 证据账本 v2
做了什么
- 从「签到成功-1.har」提取该次会话的 tdc.js, 提取密钥解密出
  errorCode=0 那次 collect 的完整明文 (1360 字节), 存为
  tcaptcha/models/success_collect_template.b64
- 新增 generate_collect_success(): 以成功样本为结构模板, Python 只回填
  轨迹/当前时间戳/随机盐, 其余环境字段 (canvas/WebGL/屏幕/语言/sd.ft) 原样保留
- 新增 _success_style_trajectory(): 按成功样本实测统计生成轨迹
  (61 点, dt 均值 30.6ms, 50% 在 0~16ms, 途经 3 个目标, 驻留抖动)
  —— 修正了统计正则不允许负增量的 bug (真实 61 点, 此前误数成 18)
- 发现成功样本用 \x00 填充 (不是空格), sd 多一个 ft 字段
- telemetry_mode=python_keyed 默认走该路径; Node 仅提取每构建 XTEA 密钥
- ANALYSIS.md 第七节重写为 v2 证据账本, 显式撤回 4 个无证据结论

验证
- 纯 Python 密文 == 真实 tdc.js 密文 (逐字节)
- 回归: test_xtea_key 11 项、test_glyph_solver 8/8
- 端到端 1ms 实测: 仍 errorCode=12 (诚实记录, 未解决)

结论(见 ANALYSIS.md 7.5-7.7)
- 12 不是"检测到机器人": 同 IP 有成功样本、参数逐字段一致、
  真 Chrome 网络栈/真人点击/CDP 均排除
- 剩余假设: H1 需已登录业务会话(未用 auth_token 调 captcha/params 复测)
            H3 需有效 qcloud.com cookie(过期 skey 复测不能定论)
- 下一步: 用登录态 auth_token 调 /api/v1/captcha/params,
  对比返回是否多出鉴权字段 —— 这是唯一未做且成本低的关键实验
2026-10-11 19:12:23 +08:00

324 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* 端到端真实浏览器求解: 官方 SDK 实例化验证码 -> 本仓库视觉求解器算坐标
* -> CDP 可信鼠标事件在 iframe 内真实点击 -> 由 SDK 自己提交 verify -> 读回调
*
* 判定「服务端到底在检测什么」的终极实验:
* 若官方 SDK + 真实浏览器点选也拿不到 ticket, 则检测在 aid/环境层, 与协议实现无关。
*
* 用法: node solve_in_browser.js <originUrl> <aid> [--headed]
*/
const { spawn, execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const CHROME = process.env.REA_BROWSER_EXECUTABLE ||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ORIGIN = process.argv[2] || 'https://1ms.run/';
const AID = process.argv[3] || '195672344';
const HEADED = process.argv.includes('--headed');
const KEEP_OPEN = process.argv.includes('--keep-open');
const PROXY_ARG = process.argv.find(a => a.startsWith('--proxy='));
const PROXY = PROXY_ARG ? PROXY_ARG.slice(8) : null;
const PY = path.resolve(__dirname, '..', '.venv', 'bin', 'python');
const ROOT = path.resolve(__dirname, '..');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-img-'));
const OUT = '/tmp/1ms';
try { fs.mkdirSync(OUT, { recursive: true }); } catch (_) {}
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function launchChrome() {
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'solve-cap-'));
const args = ['--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check',
'--disable-dev-shm-usage', `--user-data-dir=${profile}`, '--window-size=1100,900'];
if (PROXY) args.push(`--proxy-server=${PROXY}`);
if (!HEADED) args.push('--headless=new', '--disable-gpu');
args.push('about:blank');
const proc = spawn(CHROME, args, { stdio: ['ignore', 'pipe', 'pipe'] });
const portFile = path.join(profile, 'DevToolsActivePort');
let port = null;
for (let i = 0; i < 150; i++) {
if (fs.existsSync(portFile)) {
port = parseInt(fs.readFileSync(portFile, 'utf8').split('\n')[0], 10);
if (port) break;
}
await sleep(100);
}
if (!port) { proc.kill('SIGKILL'); throw new Error('Chrome 未就绪'); }
return { proc, port, profile };
}
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) c.handlers.forEach(h => h(m));
};
return c;
}
send(method, params = {}, sessionId) {
const id = ++this.id;
return new Promise((res, rej) => {
this.waits.set(id, { res, rej });
this.ws.send(JSON.stringify({ id, method, params, sessionId }));
});
}
async eval(expr, sessionId) {
const r = await this.send('Runtime.evaluate',
{ expression: expr, awaitPromise: true, returnByValue: true }, sessionId);
if (r.exceptionDetails) throw new Error('eval: ' + JSON.stringify(r.exceptionDetails).slice(0, 300));
return r.result.value;
}
}
const PY_SOLVE = `
import sys, json
sys.path.insert(0, ${JSON.stringify(ROOT)})
from tcaptcha.glyph_solver import TextClickSolver
res = TextClickSolver().solve_ex(open(sys.argv[1],'rb').read(), sys.argv[2])
print(json.dumps({"ok": res.ok, "posterior": res.posterior, "points": res.points}))
`;
async function main() {
const { proc, port, profile } = await launchChrome();
try {
const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
const cdp = await Cdp.connect(ver.webSocketDebuggerUrl);
const { targetId } = await cdp.send('Target.createTarget', { url: ORIGIN });
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
await cdp.send('Page.enable', {}, sessionId);
await cdp.send('Runtime.enable', {}, sessionId);
await cdp.send('Network.enable', {}, sessionId);
let imgReqId = null;
const pendingVerify = new Set();
let verifyResults = [];
let verifySeq = 0;
cdp.handlers.push(m => {
if (m.method === 'Network.requestWillBeSent' && /getcapbysig/.test(m.params.request.url)
&& /img_index=1/.test(m.params.request.url)) {
imgReqId = m.params.requestId;
}
if (m.method === 'Network.requestWillBeSent' &&
/cap_union_new_verify/.test(m.params.request.url)) {
const id = m.params.requestId;
pendingVerify.add(id);
const n = ++verifySeq;
// 把官方 SDK 自己发的 verify 请求体落盘, 便于与我们纯协议实现的做逐字段对照
try { fs.writeFileSync(path.join(OUT, `official_verify_${n}.txt`),
m.params.request.postData || ''); } catch (_) {}
process.stderr.write(`[net] 官方 verify #${n} 已发出 (body ` +
((m.params.request.postData || '').length) + ' 字节)\n');
}
// 竞态修复: 用 loadingFinished 立即取响应体, 不等主循环轮询
if (m.method === 'Network.loadingFinished' && pendingVerify.has(m.params.requestId)) {
const id = m.params.requestId;
pendingVerify.delete(id);
(async () => {
try {
const rb = await cdp.send('Network.getResponseBody', { requestId: id }, sessionId);
const txt = rb.base64Encoded
? Buffer.from(rb.body, 'base64').toString('utf8') : rb.body;
let code = '?';
try { code = JSON.parse(txt).errorCode; } catch (_) {}
verifyResults.push({ code, txt });
try { fs.writeFileSync(path.join(OUT, `official_verify_resp_${verifyResults.length}.json`), txt); } catch (_) {}
process.stderr.write(` [net] 官方 verify 响应 -> errorCode=${code}\n`);
} catch (e) {
process.stderr.write(' [net] 取响应体失败: ' + e.message + '\n');
}
})();
}
});
await sleep(3000);
// ---- 类人鼠标: 三次贝塞尔路径 + 变速时间戳 + 微抖动 ----
// 之前的实现只发 3 次"瞬移 + 按下", tdc.js 记到的轨迹是纯机器人的。
function humanPath(x0, y0, x1, y1, n) {
const j = () => (Math.random() - 0.5) * 36;
const cx1 = x0 + (x1 - x0) * 0.3 + j(), cy1 = y0 + (y1 - y0) * 0.3 + j();
const cx2 = x0 + (x1 - x0) * 0.7 + j(), cy2 = y0 + (y1 - y0) * 0.7 + j();
const pts = [];
for (let i = 1; i <= n; i++) {
const t = i / n, m = 1 - t;
pts.push([
m * m * m * x0 + 3 * m * m * t * cx1 + 3 * m * t * t * cx2 + t * t * t * x1,
m * m * m * y0 + 3 * m * m * t * cy1 + 3 * m * t * t * cy2 + t * t * t * y1,
]);
}
return pts;
}
// ---- 循环抽题: 只处理文字点选(题面含汉字), 其余重新出题 ----
let cur = { x: 520 + Math.random() * 120, y: 380 + Math.random() * 120 };
let vt = Date.now() / 1000; // 事件时间轴 (秒)
const moveTo = async (tx, ty) => {
const pts = humanPath(cur.x, cur.y, tx, ty, 12 + Math.floor(Math.random() * 14));
for (const [px, py] of pts) {
vt += 0.008 + Math.random() * 0.018; // 8~26ms 一个采样点
await cdp.send('Input.dispatchMouseEvent',
{ type: 'mouseMoved', x: px, y: py, button: 'none', timestamp: vt }, sessionId);
}
cur = { x: tx, y: ty };
};
const clickAt = async (tx, ty) => {
await moveTo(tx, ty);
vt += 0.05 + Math.random() * 0.12;
await cdp.send('Input.dispatchMouseEvent',
{ type: 'mousePressed', x: tx, y: ty, button: 'left', clickCount: 1, timestamp: vt }, sessionId);
vt += 0.04 + Math.random() * 0.07;
await cdp.send('Input.dispatchMouseEvent',
{ type: 'mouseReleased', x: tx, y: ty, button: 'left', clickCount: 1, timestamp: vt }, sessionId);
vt += 0.3 + Math.random() * 0.6; // 两击之间的自然停顿
};
for (let attempt = 0; attempt < 10; attempt++) {
imgReqId = null; verifyReqId = null;
await cdp.eval(`(async () => {
if (window.__cap && window.__cap.destroy) { try { window.__cap.destroy(); } catch(e){} }
window.__capcb = null;
if (typeof TencentCaptcha !== 'function') {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js';
s.onload = res; s.onerror = () => rej(new Error('sdk'));
document.head.appendChild(s);
});
}
window.__cap = new TencentCaptcha(${JSON.stringify(AID)}, r => {
window.__capcb = JSON.stringify(r); });
window.__cap.show();
return 'ok';
})()`, sessionId);
await sleep(5000);
if (!imgReqId) { process.stderr.write(`[${attempt}] 未捕获挑战图\n`); continue; }
const meta = JSON.parse(await cdp.eval(`(() => {
const cand = [...document.querySelectorAll('img,canvas,div')]
.map(e => ({ e, r: e.getBoundingClientRect() }))
.filter(o => o.r.width > 200 && o.r.height > 150 &&
(o.e.tagName !== 'DIV' ||
getComputedStyle(o.e).backgroundImage.indexOf('url(') >= 0));
cand.sort((a, b) => b.r.width * b.r.height - a.r.width * a.r.height);
// 优先用真实图片区域; 按尺寸猜会选中 tencent-captcha-dy__bg-placeholder
// (一张 7x5 的占位图, 被拉伸到 330x235, 位置与可视图片不一致)
const area = document.querySelector('.tencent-captcha-dy__image-area');
const big = area ? { e: area, r: area.getBoundingClientRect() } : cand[0];
const t = [...document.querySelectorAll('*')]
.filter(e => e.children.length === 0 && /请依次点击/.test(e.textContent || ''))
.map(e => e.textContent.trim());
const path = [];
for (let e = big && big.e; e && e.tagName; e = e.parentElement) {
path.push(e.tagName + (e.id ? '#' + e.id : '') +
(e.className && typeof e.className === 'string'
? '.' + e.className.trim().split(/\s+/).slice(0, 3).join('.') : ''));
if (path.length > 5) break;
}
const iframes = [...document.querySelectorAll('iframe')]
.map(f => ({ src: (f.src || '').slice(0, 70),
w: f.getBoundingClientRect().width,
h: f.getBoundingClientRect().height }));
return JSON.stringify({
instruction: t[0] || '',
rect: big ? { x: big.r.left, y: big.r.top, w: big.r.width, h: big.r.height } : null,
tag: big ? big.e.tagName : null,
bg: big ? getComputedStyle(big.e).backgroundImage.slice(0, 90) : null,
src: big && big.e.src ? big.e.src.slice(0, 90) : null,
path, iframes,
});
})()`, sessionId));
process.stderr.write(' DOM: ' + JSON.stringify({ tag: meta.tag, bg: meta.bg,
src: meta.src, path: meta.path, iframes: meta.iframes }) + '\n');
// 注意: 题面里汉字之间是有空格的 ("请依次点击:齿 奥 柏 "),
// 不能写成 [\u4e00-\u9fa5]{3} 连排, 否则永远匹配不上 (空转 bug)。
const m2 = /请依次点击:\s*(.+?)\s*$/.exec(meta.instruction);
const toks = m2 ? m2[1].split(/\s+/).filter(Boolean) : [];
const isText = toks.length === 3 && toks.every(t => /^[\u4e00-\u9fa5]{1,2}$/.test(t));
process.stderr.write(`[${attempt}] 题面=${JSON.stringify(meta.instruction)} 目标=${JSON.stringify(toks)} ` +
`文字点选=${isText} rect=${!!meta.rect}\n`);
if (!isText || !meta.rect) { process.stderr.write(' -> 非文字点选, 换题\n'); continue; }
const body = await cdp.send('Network.getResponseBody', { requestId: imgReqId }, sessionId);
const buf = body.base64Encoded ? Buffer.from(body.body, 'base64') : Buffer.from(body.body, 'utf8');
const imgPath = path.join(TMP, `cap_${attempt}.jpg`);
fs.writeFileSync(imgPath, buf);
const solved = JSON.parse(execFileSync(PY, ['-c', PY_SOLVE, imgPath, meta.instruction],
{ encoding: 'utf8' }));
process.stderr.write(` 求解 ${JSON.stringify(solved.points)} post=${solved.posterior.toFixed(4)}\n`);
if (!solved.ok) continue;
const shot = async (name) => {
try {
const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId);
const f = path.join(OUT, name);
fs.writeFileSync(f, Buffer.from(r.data, 'base64'));
process.stderr.write(' [截图] ' + f + '\n');
} catch (e) { process.stderr.write(' [截图失败] ' + e.message + '\n'); }
};
await shot(`shot_${attempt}_before.png`);
const scale = meta.rect.w / 672.0;
await sleep(1200 + Math.random() * 2200); // 人读题需要时间
vt = Date.now() / 1000;
for (const [x, y] of solved.points) {
await clickAt(meta.rect.x + x * scale, meta.rect.y + y * scale);
}
await sleep(800);
// 关键: 点选类验证码点完目标后必须再点「确定」, SDK 才会提交 verify
const btn = await cdp.eval(`(() => {
const all = [...document.querySelectorAll('div,button,a,span')];
const b = all.filter(e => (e.textContent || '').trim() === '确定' &&
e.getBoundingClientRect().width > 40)[0];
if (!b) return null;
const r = b.getBoundingClientRect();
return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
})()`, sessionId);
if (btn) {
const b = JSON.parse(btn);
await clickAt(b.x, b.y);
process.stderr.write(' 已沿类人轨迹点击「确定」按钮\n');
} else {
process.stderr.write(' [warn] 未找到「确定」按钮\n');
}
await sleep(1200);
await shot(`shot_${attempt}_after.png`);
process.stderr.write(' 三次点击 + 确定 已派发, 等 verify...\n');
const before = verifyResults.length;
for (let i = 0; i < 12; i++) {
await sleep(1000);
if (verifyResults.length > before) {
const r = verifyResults[verifyResults.length - 1];
process.stderr.write(` [结果] 官方 SDK verify -> errorCode=${r.code}\n`);
console.log(JSON.stringify({ attempt, instruction: meta.instruction,
points: solved.points, errorCode: r.code,
response: r.txt.slice(0, 400) }));
return;
}
const cb = await cdp.eval('window.__capcb || ""', sessionId);
if (cb) { process.stderr.write(' [回调] ' + cb + '\n'); console.log(cb); return; }
}
process.stderr.write(' 本轮无 verify 响应, 换题重试\n');
}
process.stderr.write('[x] 10 轮内未拿到文字点选的 verify 结果\n');
} finally {
if (KEEP_OPEN) {
process.stderr.write('[i] --keep-open: 浏览器保持打开, 你可以在窗口里直接看\n');
} else {
proc.kill('SIGKILL');
try { fs.rmSync(profile, { recursive: true, force: true }); } catch (_) {}
}
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (_) {}
}
}
main().catch(e => { console.error('ERR', e.message); process.exit(1); });