511 lines
18 KiB
JavaScript
511 lines
18 KiB
JavaScript
'use strict';
|
||
/**
|
||
* hCaptcha Solver (Node.js)
|
||
*
|
||
* 完整流程:
|
||
* checksiteconfig → hsw(req) 计算 n → 构建加密体 → getcaptcha → 解密响应 → 拿 token
|
||
*
|
||
* 使用 hsw.js 在 Node 沙盒中运行(全局污染方式)
|
||
*/
|
||
|
||
const vm = require('vm');
|
||
const { readFileSync } = require('fs');
|
||
const { join } = require('path');
|
||
const msgpack = require('msgpack-lite');
|
||
const windowMock = require('./sandbox/mocks/window');
|
||
const { applySandboxPatches } = require('./sandbox/mocks/index');
|
||
const { Logger } = require('./utils/logger');
|
||
|
||
const logger = new Logger('hcaptcha_solver');
|
||
|
||
// 保存原始 fetch(供真实网络请求使用)
|
||
const realFetch = globalThis.fetch;
|
||
|
||
// ── 常量 ──────────────────────────────────────────────────────
|
||
const HCAPTCHA_API = 'https://api.hcaptcha.com';
|
||
const HCAPTCHA_JS = 'https://js.hcaptcha.com/1/api.js';
|
||
const HCAPTCHA_CDN = 'https://newassets.hcaptcha.com';
|
||
|
||
// ── 伪造 motion data ──────────────────────────────────────────
|
||
function generateMotionData() {
|
||
const now = Date.now();
|
||
const st = now - 3000 - Math.floor(Math.random() * 1000);
|
||
|
||
// 随机鼠标轨迹
|
||
const mm = [];
|
||
let x = 200 + Math.floor(Math.random() * 100);
|
||
let y = 300 + Math.floor(Math.random() * 100);
|
||
const steps = 15 + Math.floor(Math.random() * 10);
|
||
for (let i = 0; i < steps; i++) {
|
||
x += Math.floor(Math.random() * 6) - 3;
|
||
y += Math.floor(Math.random() * 6) - 3;
|
||
mm.push([x, y, st + i * (80 + Math.floor(Math.random() * 40))]);
|
||
}
|
||
|
||
return {
|
||
st,
|
||
dct: st + 200 + Math.floor(Math.random() * 100),
|
||
mm,
|
||
'mm-mp': 15.42857142857143,
|
||
md: [[mm[0][0], mm[0][1], mm[0][2] + 500]],
|
||
'md-mp': 0,
|
||
mu: [[mm[0][0], mm[0][1], mm[0][2] + 600]],
|
||
'mu-mp': 0,
|
||
kd: [],
|
||
'kd-mp': 0,
|
||
ku: [],
|
||
'ku-mp': 0,
|
||
topLevel: {
|
||
st,
|
||
sc: {
|
||
availWidth: 1920,
|
||
availHeight: 1040,
|
||
width: 1920,
|
||
height: 1080,
|
||
colorDepth: 24,
|
||
pixelDepth: 24,
|
||
availLeft: 0,
|
||
availTop: 0,
|
||
},
|
||
nv: {
|
||
hardwareConcurrency: 8,
|
||
deviceMemory: 8,
|
||
},
|
||
dr: '',
|
||
inv: false,
|
||
exec: false,
|
||
},
|
||
v: 1,
|
||
};
|
||
}
|
||
|
||
// ── HSW 沙盒管理 ──────────────────────────────────────────────
|
||
class HswBridge {
|
||
constructor() {
|
||
this.hswFn = null;
|
||
this.initialized = false;
|
||
this._ctx = null;
|
||
}
|
||
|
||
/**
|
||
* 构建 vm 沙盒上下文,将 mock window 注入隔离环境
|
||
* @param {object} fingerprint - 指纹覆盖
|
||
*/
|
||
_buildContext(fingerprint) {
|
||
const ctx = Object.create(null);
|
||
|
||
// 把 windowMock 上所有 key 复制进 ctx(浅拷贝)
|
||
for (const key of Reflect.ownKeys(windowMock)) {
|
||
try { ctx[key] = windowMock[key]; } catch (_) {}
|
||
}
|
||
|
||
// vm 必需的自引用
|
||
ctx.global = ctx;
|
||
ctx.globalThis = ctx;
|
||
ctx.window = ctx;
|
||
ctx.self = ctx;
|
||
|
||
// 透传 console(调试用)
|
||
ctx.console = console;
|
||
|
||
// 保证 Promise / 定时器在 vm 里可用
|
||
ctx.Promise = Promise;
|
||
ctx.setTimeout = setTimeout;
|
||
ctx.clearTimeout = clearTimeout;
|
||
ctx.setInterval = setInterval;
|
||
ctx.clearInterval = clearInterval;
|
||
ctx.queueMicrotask = queueMicrotask;
|
||
|
||
// 应用指纹覆盖
|
||
if (fingerprint.userAgent && ctx.navigator) {
|
||
ctx.navigator.userAgent = fingerprint.userAgent;
|
||
ctx.navigator.appVersion = fingerprint.userAgent.replace('Mozilla/', '');
|
||
}
|
||
if (fingerprint.platform && ctx.navigator) {
|
||
ctx.navigator.platform = fingerprint.platform;
|
||
}
|
||
if (fingerprint.host && ctx.location?.ancestorOrigins) {
|
||
ctx.location.ancestorOrigins[0] = `https://${fingerprint.host}`;
|
||
}
|
||
|
||
const vmCtx = vm.createContext(ctx);
|
||
|
||
// Apply escape defense + error stack rewriting AFTER context creation
|
||
applySandboxPatches(vmCtx);
|
||
|
||
return vmCtx;
|
||
}
|
||
|
||
/**
|
||
* 在 vm 沙盒中加载并执行 hsw.js
|
||
* @param {string} hswPath - hsw.js 文件路径
|
||
* @param {object} fingerprint - 指纹覆盖
|
||
*/
|
||
async init(hswPath, fingerprint = {}) {
|
||
if (this.initialized) return;
|
||
|
||
const code = readFileSync(hswPath, 'utf-8');
|
||
logger.info(`hsw.js 已加载 (${(code.length / 1024).toFixed(1)} KB)`);
|
||
|
||
const ctx = this._buildContext(fingerprint);
|
||
this._ctx = ctx;
|
||
|
||
const script = new vm.Script(code, { filename: 'hsw.js' });
|
||
|
||
try {
|
||
script.runInContext(ctx, { timeout: 10000 });
|
||
} catch (err) {
|
||
logger.error(`hsw.js 执行失败: ${err.message}`);
|
||
throw err;
|
||
}
|
||
|
||
// 查找 hsw 函数
|
||
if (typeof ctx.hsw === 'function') {
|
||
this.hswFn = ctx.hsw;
|
||
} else if (typeof ctx.window?.hsw === 'function') {
|
||
this.hswFn = ctx.window.hsw;
|
||
}
|
||
|
||
if (!this.hswFn) {
|
||
throw new Error('hsw function not found after execution');
|
||
}
|
||
|
||
this.initialized = true;
|
||
logger.success('Bridge 已就绪 (vm 沙盒)');
|
||
}
|
||
|
||
/** 计算 PoW n 值: hsw(req_jwt_string) */
|
||
async getN(req) {
|
||
return this.hswFn(req);
|
||
}
|
||
|
||
/** 加密请求体: hsw(1, data) */
|
||
async encrypt(data) {
|
||
return this.hswFn(1, data);
|
||
}
|
||
|
||
/** 解密响应体: hsw(0, data) */
|
||
async decrypt(data) {
|
||
return this.hswFn(0, data);
|
||
}
|
||
}
|
||
|
||
// ── HTTP 工具 ─────────────────────────────────────────────────
|
||
const DEFAULT_HEADERS = {
|
||
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36',
|
||
'Accept': '*/*',
|
||
'Accept-Language': 'en-US,en;q=0.9',
|
||
'sec-ch-ua': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
|
||
'sec-ch-ua-mobile': '?0',
|
||
'sec-ch-ua-platform': '"Linux"',
|
||
};
|
||
|
||
async function httpGet(url, headers = {}) {
|
||
const resp = await realFetch(url, {
|
||
method: 'GET',
|
||
headers: { ...DEFAULT_HEADERS, ...headers },
|
||
});
|
||
const text = await resp.text();
|
||
logger.info(`HTTP GET ${url.substring(0, 80)}... → ${resp.status}`);
|
||
return { status: resp.status, text, headers: resp.headers };
|
||
}
|
||
|
||
async function httpPost(url, body, headers = {}) {
|
||
const isBuffer = body instanceof Uint8Array || Buffer.isBuffer(body);
|
||
const opts = {
|
||
method: 'POST',
|
||
headers: {
|
||
...DEFAULT_HEADERS,
|
||
...(isBuffer
|
||
? { 'Content-Type': 'application/octet-stream' }
|
||
: { 'Content-Type': 'application/x-www-form-urlencoded' }),
|
||
...headers,
|
||
},
|
||
body: isBuffer ? body : body,
|
||
};
|
||
const resp = await realFetch(url, opts);
|
||
logger.info(`HTTP POST ${url.substring(0, 80)}... → ${resp.status}`);
|
||
return resp;
|
||
}
|
||
|
||
// ── 主求解器 ──────────────────────────────────────────────────
|
||
class HCaptchaSolver {
|
||
/**
|
||
* @param {object} opts
|
||
* @param {string} opts.sitekey
|
||
* @param {string} opts.host - 嵌入 hCaptcha 的站点域名
|
||
* @param {string} [opts.rqdata] - 可选附加数据
|
||
* @param {string} [opts.hswPath] - hsw.js 本地路径
|
||
*/
|
||
constructor(opts) {
|
||
this.sitekey = opts.sitekey;
|
||
this.host = opts.host;
|
||
this.rqdata = opts.rqdata || null;
|
||
this.hswPath = opts.hswPath || join(__dirname, '../asset/hsw.js');
|
||
this.bridge = new HswBridge();
|
||
this.version = null;
|
||
}
|
||
|
||
/** 1. 从 api.js 获取最新版本 hash */
|
||
async fetchVersion() {
|
||
const { text } = await httpGet(HCAPTCHA_JS);
|
||
// 尝试多种模式匹配版本 hash
|
||
const patterns = [
|
||
/captcha\/v1\/([a-f0-9]{40})\//i, // captcha/v1/HASH/
|
||
/\/c\/([a-f0-9]{40})\//i, // /c/HASH/
|
||
/v=([a-f0-9]{40})/i, // v=HASH
|
||
/["']([a-f0-9]{40})["']/, // 直接引用的40位hash
|
||
];
|
||
for (const pat of patterns) {
|
||
const m = text.match(pat);
|
||
if (m) {
|
||
this.version = m[1];
|
||
logger.info(`获取到最新 hCaptcha 版本: ${this.version}`);
|
||
return this.version;
|
||
}
|
||
}
|
||
throw new Error('无法从 api.js 提取版本 hash');
|
||
}
|
||
|
||
/** 2. 初始化 Bridge(加载 hsw.js 到沙盒) */
|
||
async initBridge() {
|
||
let hswCode;
|
||
// 优先用本地 hsw.js
|
||
try {
|
||
readFileSync(this.hswPath);
|
||
hswCode = this.hswPath;
|
||
logger.info(`使用本地 hsw.js: ${this.hswPath}`);
|
||
} catch {
|
||
// 从 CDN 下载
|
||
if (!this.version) await this.fetchVersion();
|
||
const url = `${HCAPTCHA_CDN}/c/${this.version}/hsw.js`;
|
||
logger.info(`从 CDN 下载 hsw.js: ${url}`);
|
||
const { text } = await httpGet(url);
|
||
// 写入临时文件
|
||
const tmp = join(__dirname, '../asset/hsw_latest.js');
|
||
require('fs').writeFileSync(tmp, text);
|
||
hswCode = tmp;
|
||
logger.info(`hsw.js 已保存: ${tmp}`);
|
||
}
|
||
|
||
await this.bridge.init(hswCode, {
|
||
host: this.host,
|
||
});
|
||
}
|
||
|
||
/** 3. checksiteconfig */
|
||
async checkSiteConfig() {
|
||
if (!this.version) await this.fetchVersion();
|
||
|
||
const params = new URLSearchParams({
|
||
v: this.version,
|
||
host: this.host,
|
||
sitekey: this.sitekey,
|
||
sc: '1',
|
||
swa: '1',
|
||
spst: '0',
|
||
});
|
||
|
||
const t0 = Date.now();
|
||
const url = `${HCAPTCHA_API}/checksiteconfig?${params}`;
|
||
const resp = await realFetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
...DEFAULT_HEADERS,
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
'Origin': 'https://newassets.hcaptcha.com',
|
||
'Referer': 'https://newassets.hcaptcha.com/',
|
||
},
|
||
body: params.toString(),
|
||
});
|
||
const data = await resp.json();
|
||
const dur = Date.now() - t0;
|
||
logger.info(`HTTP POST ${url.substring(0, 80)}... → ${resp.status}`);
|
||
|
||
// 提取 set-cookie(hmt_id 等),后续请求需要带上
|
||
const rawCookie = resp.headers.get('set-cookie') || '';
|
||
const hmtMatch = rawCookie.match(/hmt_id=[^;]+/);
|
||
this.cookie = hmtMatch ? hmtMatch[0] : '';
|
||
if (this.cookie) logger.info(`获取到 cookie: ${this.cookie}`);
|
||
|
||
const pass = data.pass !== false;
|
||
const cType = data.c?.type || 'unknown';
|
||
logger.info(`checksiteconfig: pass=${pass}, type=${cType}, duration=${dur}ms`);
|
||
|
||
if (!data.c) {
|
||
throw new Error(`checksiteconfig 缺少 challenge 字段: ${JSON.stringify(data)}`);
|
||
}
|
||
|
||
return data;
|
||
}
|
||
|
||
/** 4. 构建并加密请求体 */
|
||
async buildEncryptedBody(challenge) {
|
||
// 计算 PoW n 值
|
||
logger.info('计算 PoW n 值...');
|
||
const n = await this.bridge.getN(challenge.c.req);
|
||
logger.info(`n = ${typeof n === 'string' ? n.substring(0, 40) + '...' : n}`);
|
||
|
||
// 构建 payload
|
||
const motionData = generateMotionData();
|
||
const payload = {
|
||
v: this.version,
|
||
sitekey: this.sitekey,
|
||
host: this.host,
|
||
hl: 'en',
|
||
motionData: JSON.stringify(motionData),
|
||
n,
|
||
c: JSON.stringify(challenge.c),
|
||
pst: false,
|
||
};
|
||
|
||
// 如果有 rqdata,加入
|
||
if (this.rqdata) {
|
||
payload.rqdata = this.rqdata;
|
||
}
|
||
|
||
logger.info('构建加密请求体...');
|
||
|
||
// ── 正确的加密流程(与 flow_manager.js / h.html 一致)──
|
||
// Step 1: 克隆 payload,去掉 c 字段(c 会单独放在外层)
|
||
const payloadClone = { ...payload };
|
||
const cValue = payloadClone.c;
|
||
delete payloadClone.c;
|
||
|
||
// Step 2: msgpack 编码后再加密: hsw(1, msgpack.encode(payload_without_c))
|
||
const msgpackPayload = msgpack.encode(payloadClone);
|
||
logger.info(`msgpack 编码大小: ${msgpackPayload.length} bytes`);
|
||
|
||
const encrypted = await this.bridge.encrypt(msgpackPayload);
|
||
|
||
// 诊断:encrypted 的类型和内容
|
||
const etype = typeof encrypted;
|
||
const ector = encrypted?.constructor?.name || 'unknown';
|
||
logger.info(`encrypt 返回类型: typeof=${etype}, constructor=${ector}`);
|
||
if (encrypted instanceof Uint8Array || Buffer.isBuffer(encrypted)) {
|
||
logger.info(`encrypt 返回 Uint8Array/Buffer, length=${encrypted.length}`);
|
||
} else if (typeof encrypted === 'string') {
|
||
logger.info(`encrypt 返回 string, length=${encrypted.length}`);
|
||
} else {
|
||
logger.info(`encrypt 返回: ${JSON.stringify(encrypted)?.substring(0, 200)}`);
|
||
}
|
||
|
||
if (!encrypted) {
|
||
throw new Error('hsw encrypt 返回 null/undefined');
|
||
}
|
||
|
||
// Step 3: 二次打包: body = msgpack.encode([JSON.stringify(c), encrypted_bytes])
|
||
const cString = typeof cValue === 'string' ? cValue : JSON.stringify(cValue);
|
||
const body = msgpack.encode([cString, encrypted]);
|
||
|
||
logger.info(`最终 body 大小: ${body.length} bytes, first10=[${Array.from(body.slice(0, 10)).map(b => '0x' + b.toString(16).padStart(2, '0')).join(',')}]`);
|
||
|
||
// 导出加密体供 curl_cffi 测试
|
||
require('fs').writeFileSync('body.bin', body);
|
||
logger.info('已导出加密体到 body.bin');
|
||
|
||
return body;
|
||
}
|
||
|
||
/** 5. getcaptcha → 拿 token */
|
||
async getCaptcha(encryptedBody) {
|
||
const url = `${HCAPTCHA_API}/getcaptcha/${this.sitekey}`;
|
||
|
||
const resp = await realFetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
...DEFAULT_HEADERS,
|
||
'Accept': 'application/json, application/octet-stream',
|
||
'Content-Type': 'application/octet-stream',
|
||
'Origin': 'https://newassets.hcaptcha.com',
|
||
'Referer': 'https://newassets.hcaptcha.com/',
|
||
'sec-fetch-dest': 'empty',
|
||
'sec-fetch-mode': 'cors',
|
||
'sec-fetch-site': 'same-site',
|
||
'sec-fetch-storage-access': 'none',
|
||
'priority': 'u=1, i',
|
||
...(this.cookie ? { 'Cookie': this.cookie } : {}),
|
||
},
|
||
body: encryptedBody,
|
||
});
|
||
logger.info(`HTTP POST ${url} → ${resp.status}`);
|
||
logger.info(`响应 content-type: ${resp.headers.get('content-type')}`);
|
||
|
||
if (!resp.ok) {
|
||
const errText = await resp.text();
|
||
logger.error(`getcaptcha 失败 (${resp.status}): ${errText.substring(0, 200)}`);
|
||
throw new Error(`getcaptcha HTTP ${resp.status}: ${errText.substring(0, 200)}`);
|
||
}
|
||
|
||
// 尝试解密响应
|
||
const contentType = resp.headers.get('content-type') || '';
|
||
let result;
|
||
|
||
if (contentType.includes('json')) {
|
||
// 明文 JSON 响应
|
||
result = await resp.json();
|
||
} else {
|
||
// 二进制加密响应
|
||
const rawBuf = await resp.arrayBuffer();
|
||
const raw = new Uint8Array(rawBuf);
|
||
logger.info('解密响应...');
|
||
const decrypted = await this.bridge.decrypt(raw);
|
||
try {
|
||
result = msgpack.decode(decrypted);
|
||
} catch {
|
||
// 可能是 JSON 字符串
|
||
result = JSON.parse(new TextDecoder().decode(decrypted));
|
||
}
|
||
}
|
||
|
||
const pass = result.pass || result.generated_pass_UUID;
|
||
logger.info(`getcaptcha 结果: pass=${!!pass}`);
|
||
|
||
return result;
|
||
}
|
||
|
||
/** 完整求解流程 */
|
||
async solve() {
|
||
logger.info(`开始求解 sitekey=${this.sitekey.substring(0, 12)}... host=${this.host}`);
|
||
const t0 = Date.now();
|
||
|
||
try {
|
||
// 步骤 1: 获取版本
|
||
await this.fetchVersion();
|
||
|
||
// 步骤 2: 初始化 Bridge
|
||
await this.initBridge();
|
||
|
||
// 步骤 3: checksiteconfig
|
||
const config = await this.checkSiteConfig();
|
||
|
||
// 步骤 4: 构建加密体
|
||
const body = await this.buildEncryptedBody(config);
|
||
|
||
// 步骤 5: getcaptcha
|
||
const result = await this.getCaptcha(body);
|
||
|
||
const token = result.generated_pass_UUID || result.pass;
|
||
const dur = ((Date.now() - t0) / 1000).toFixed(2);
|
||
|
||
if (token && typeof token === 'string' && token.startsWith('P1_')) {
|
||
logger.success(`✅ 求解成功! (${dur}s)`);
|
||
logger.info(`Token: ${token.substring(0, 50)}...`);
|
||
return { success: true, token, result };
|
||
} else if (result.pass === true) {
|
||
logger.success(`✅ pass=true (${dur}s)`);
|
||
return { success: true, token: token || true, result };
|
||
} else {
|
||
logger.error(`❌ 求解失败: ${JSON.stringify(result).substring(0, 200)}`);
|
||
return { success: false, result };
|
||
}
|
||
} catch (err) {
|
||
logger.error(`求解异常: ${err.message}`);
|
||
logger.error(err.stack);
|
||
return { success: false, error: err.message };
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = { HCaptchaSolver, HswBridge, generateMotionData };
|