大神论坛

找回密码
快速注册
查看: 7 | 回复: 0

[其他] WEB逆向之360天御验证码详解 滑块识别 混淆解混淆 轨迹模拟等

digest

主题

帖子

0

积分

初入江湖

UID
669
积分
0
精华
威望
0 点
违规
大神币
68 枚
注册时间
2023-10-14 10:50
发表于 2025-12-29 22:52
本帖最后由 阿拉灯神丁 于 2025-12-29 22:52 编辑

声明

本文章中所有内容仅供学习交流使用,不用于其他任何目的,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!若有侵权,请联系作者删除。

前言

分析网站是官网接口,网址:https://tianyu.360.cn/#/global/details/sliding-puzzle

接口分析

auth接口获取图片信息,跟校验接口绑定的信息

第一个接口有有效性校验,接口失效返回值如下:

{"error":302,"msg":"参数错误","data":"请求时间间隔过大"}

check接口需要从前面auth接口获取参数,加密的参数只有report

校验成功返回值

{
"error": 0,
"msg": "成功",
"data": {
"result": true,
"token": "d0dadf3f91b84e5a6a9c0fbaed101033"
}
}

校验失败返回值

{
"error": 0,
"msg": "成功",
"data": {
"result": false,
"token": "6acc453ec9b0f9e1dea29d8323906dac"
}
}

成功与失败的区别在于result

反调试分析

无限debugger总共出现了两次,第一次是在quc7.js文件,这是在进入网站的时候出现的debugger

第二次而是在打开验证码jgbCaptcha.js的时候出现,可以直接删除无限debugger代码也可以去hook掉

我比较懒,直接禁用断点(电脑比较好,不会卡死)

解混淆

打开代码调试都是清一色的混淆

混淆会将解混淆函数进行链式赋值 ,后续ast解的时候也需要去进行反向数据流分析

解密函数分析

打断点进行解混淆函数内部,a0_0x4a83函数进去之后就会执行a0_0x3bf0函数返回一个大数组。

扣下来进行解混淆,跟网页上解的一致

a0_0x4a83函数分析

function a0_0x4a83(_0x43cb74, _0x2c52ef) {
// 调用 a0_0x3bf0 函数,通常返回一个字符串数组,用于混淆字符串查找
const _0x29f25d =['大数组'];
// 重新定义 a0_0x4a83 函数,覆盖外层定义,实现字符串解密逻辑
return (
(a0_0x4a83 = function (_0x16ea67, _0x2f945f) {
// _0x16ea67 减去 0x125 (293 十进制),得到字符串数组的索引
_0x16ea67 -= 0x125;
// 从混淆字符串数组中取出对应的字符串
let _0x4aa991 = _0x29f25d[_0x16ea67];
// 如果函数属性 WHfHdj 未定义,表示首次调用,需要初始化解密相关函数
if (a0_0x4a83.WHfHdj === undefined) {
// 定义 Base64 解码函数
const _0x42c61e = function (_0x1bbdbc) {
// Base64 字符表
const _0x4e1232 =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';
let _0x552756 = ''; // 用于存放解码后的二进制字符串
let _0x506698 = ''; // 用于存放经过 URI 编码的字符串
// 遍历输入字符串,做 Base64 解码
for (
let _0x450fe8 = 0x0, _0x45cc8c, _0xc138c8, _0x495cc0 = 0x0;
(_0xc138c8 = _0x1bbdbc.charAt(_0x495cc0++));
~_0xc138c8 &&
((_0x45cc8c =
_0x450fe8 % 0x4 ? _0x45cc8c * 0x40 + _0xc138c8 : _0xc138c8),
_0x450fe8++ % 0x4)
? (_0x552756 += String.fromCharCode(
0xff & (_0x45cc8c >> ((-0x2 * _0x450fe8) & 0x6)),
))
: 0x0
) {
// 获取当前字符在 Base64 字符表中的索引
_0xc138c8 = _0x4e1232.indexOf(_0xc138c8);
}
// 把解码后的每个字符转换成 %xx 格式的 URI 编码字符串
for (
let _0x4e86d0 = 0x0, _0x4b903a = _0x552756.length;
_0x4e86d0 < _0x4b903a;
_0x4e86d0++
) {
_0x506698 += `%${`00${_0x552756
.charCodeAt(_0x4e86d0)
.toString(16)}`.slice(-2)}`;
}
// 用 decodeURIComponent 解码还原成原始字符串
return decodeURIComponent(_0x506698);
};
// 定义 RC4 解密函数,参数是要解密的字符串和密钥
const _0x9ab465 = function (_0x3614d5, _0x3f6c1) {
const _0x535bee = []; // 256字节S盒
let _0x37a0ac = 0x0;
let _0x19c8f6;
let _0x16b8c5 = ''; // 存放解密结果
// 先用 Base64 解码函数解码密文
_0x3614d5 = _0x42c61e(_0x3614d5);
let _0x20fcb3;
// 初始化 S 盒为 [0,1,2,...255]
for (_0x20fcb3 = 0x0; _0x20fcb3 < 0x100; _0x20fcb3++) {
_0x535bee[_0x20fcb3] = _0x20fcb3;
}
// 用密钥对 S 盒进行置换,KSA 算法阶段
for (_0x20fcb3 = 0x0; _0x20fcb3 < 0x100; _0x20fcb3++) {
_0x37a0ac =
(_0x37a0ac +
_0x535bee[_0x20fcb3] +
_0x3f6c1.charCodeAt(_0x20fcb3 % _0x3f6c1.length)) %
0x100;
_0x19c8f6 = _0x535bee[_0x20fcb3];
_0x535bee[_0x20fcb3] = _0x535bee[_0x37a0ac];
_0x535bee[_0x37a0ac] = _0x19c8f6;
}
// 初始化 i, j 指针为 0,准备 PRGA 随机输出阶段
_0x20fcb3 = 0x0;
_0x37a0ac = 0x0;
// 遍历输入字符串进行异或解密
for (let _0x1d322f = 0x0; _0x1d322f < _0x3614d5.length; _0x1d322f++) {
_0x20fcb3 = (_0x20fcb3 + 1) % 0x100;
_0x37a0ac = (_0x37a0ac + _0x535bee[_0x20fcb3]) % 0x100;
// 交换 S 盒两个值
_0x19c8f6 = _0x535bee[_0x20fcb3];
_0x535bee[_0x20fcb3] = _0x535bee[_0x37a0ac];
_0x535bee[_0x37a0ac] = _0x19c8f6;
// 生成伪随机字节,与密文对应字符异或,得到明文字符
_0x16b8c5 += String.fromCharCode(
_0x3614d5.charCodeAt(_0x1d322f) ^
_0x535bee[
(_0x535bee[_0x20fcb3] + _0x535bee[_0x37a0ac]) % 0x100
],
);
}
// 返回解密后的字符串
return _0x16b8c5;
};
// 把 RC4 解密函数保存到 a0_0x4a83 的 vkNTjd 属性,方便后续调用
a0_0x4a83.vkNTjd = _0x9ab465;
// 保存当前函数调用的参数对象到 _0x43cb74
_0x43cb74 = arguments;
// 标记初始化已完成,避免重复定义函数
a0_0x4a83.WHfHdj = true;
}
// 取混淆字符串数组第一个元素(一般是一个整数字符或偏移量)
const _0x3bf02c = _0x29f25d[0x0];
// 计算缓存键值,索引 + 第一个元素的值
const _0x4a8319 = _0x16ea67 + _0x3bf02c;
// 取缓存结果(如果之前解密过,会存在 arguments 对象中)
const _0x151d12 = _0x43cb74[_0x4a8319];
return (
// 如果缓存不存在,进行解密并缓存
!_0x151d12
? (
// 初始化标记,表示进行了解密
a0_0x4a83.YDqMht === undefined && (a0_0x4a83.YDqMht = true),
// 调用 RC4 解密方法,解密混淆字符串,传入密钥
(_0x4aa991 = a0_0x4a83.vkNTjd(_0x4aa991, _0x2f945f)),
// 缓存解密结果,避免重复解密
(_0x43cb74[_0x4a8319] = _0x4aa991)
)
// 如果缓存存在,直接使用缓存结果
: (_0x4aa991 = _0x151d12),
// 返回解密后的字符串
_0x4aa991
);
}),
// 调用刚刚重新定义的函数
a0_0x4a83(_0x43cb74, _0x2c52ef)
);
}

然后除了这个字符串解混淆之外还有对象方法混淆

具体哪些混淆都已经分析完毕,接下来开始解混淆

不清楚各位ast大佬的思路是怎么解的,我的思路比较愚笨可能不是最优解不是很好,不过暂且能用,望多多指教!目前我的思路如下:

  • 收集所有混淆用的对象和函数映射
  • 创建安全的沙箱环境执行解密函数
  • 遍历AST,将混淆调用替换为实际值
  • 处理链式别名,确保所有变体都能被识别


混淆代码主要特征:

  1. 存在一个大数组存储所有字符串
  2. 存在解密函数 a0_0x4a83 用于从数组中取值
  3. 存在多个对象字面量,属性值为字符串或函数包装
  4. 代码中大量使用十六进制数字

文件说明:

  • jie.js: 解密函数/_0x1b59d6对象文件
  • demo.js: 混淆的源文件
  • decodeResult.js: 输出文件

整体解混淆流程如下:

第一步:收集对象字面量映射

混淆代码中存在大量对象字面量,用于存储字符串和函数包装:

var _0xbm2n = {
"OBvQE": "admin", // 字符串字面量
"pQrSt": "123456", // 字符串字面量
"uVwXy": function(a, b) { // 比较函数包装
return a === b;
},
"eFgHi": function(fn, x) { // 函数调用包装
return fn(x);
}
};

AST代码实现:

let objectMap = new Map();  // objName -> { propName -> value }
traverse(decryptAst, {
VariableDeclarator(path) {
const {id, init} = path.node;
if (!types.isIdentifier(id) || !types.isObjectExpression(init)) return;
const objName = id.name;
const props = {};
let hasValidProps = false;
for (const prop of init.properties) {
if (prop.computed || types.isSpreadElement(prop)) continue;
let keyName;
if (types.isIdentifier(prop.key)) {
keyName = prop.key.name;
} else if (types.isStringLiteral(prop.key)) {
keyName = prop.key.value;
} else {
continue;
}
// 字符串字面量
if (types.isStringLiteral(prop.value)) {
props[keyName] = { type: 'literal', value: prop.value.value };
hasValidProps = true;
}
// 数字字面量
else if (types.isNumericLiteral(prop.value)) {
props[keyName] = { type: 'literal', value: prop.value.value };
hasValidProps = true;
}
// 函数包装处理
}
if (hasValidProps) {
objectMap.set(objName, props);
}
}
});

第二步:收集解密函数别名

混淆代码中解密函数会被多次赋值给不同变量,形成别名链:

var a0_0x4a83 = function(i) { ... };  // 原始解密函数
var a0l = a0_0x4a83; // 一级别名
function test() {
var bd = a0l; // 二级别名
var bT = bd; // 三级别名
console.log(bT(0x100)); // 实际调用
}

需要多遍扫描才能收集完整的别名链:

let aliasMap = new Map();
// 先从 jie.js 找一级别名
traverse(decryptAst, {
VariableDeclarator(path) {
const {id, init} = path.node;
if (init && init.type === 'Identifier' && init.name === 'a0_0x4a83') {
if (id && id.type === 'Identifier') {
aliasMap.set(id.name, 'a0_0x4a83');
}
}
}
});
// 再从 demo.js 找
traverse(ast, {
VariableDeclarator(path) {
const {id, init} = path.node;
if (init && init.type === 'Identifier' && init.name === 'a0_0x4a83') {
if (id && id.type === 'Identifier') {
console.log(`找到一级别名: ${id.name} = a0_0x4a83`);
aliasMap.set(id.name, 'a0_0x4a83');
}
}
}
});
// 多遍扫描链式别名
let foundNew = true;
let round = 1;
while (foundNew) {
foundNew = false;
traverse(ast, {
VariableDeclarator(path) {
const {id, init} = path.node;
if (init && init.type === 'Identifier') {
if (aliasMap.has(init.name) && id && id.type === 'Identifier' && !aliasMap.has(id.name)) {
console.log(`找到${round + 1}级别名: ${id.name} = ${init.name}`);
aliasMap.set(id.name, 'a0_0x4a83');
foundNew = true;
}
}
}
});
round++;
if (round > 10) break; // 防止死循环
}
console.log(`\n共找到 ${aliasMap.size} 个解密函数别名\n`);

第三步:创建沙箱执行环境

为了安全地执行解密函数,需要创建一个沙箱环境:

const vm = require('vm');
// 模拟浏览器环境
const sandbox = {
console,
window: {},
document: {
createElement: () => ({}),
getElementsByTagName: () => [],
head: { appendChild: () => {} }
},
navigator: { userAgent: '' },
location: { href: '', hostname: '' },
setTimeout: () => {},
setInterval: () => {},
clearTimeout: () => {},
clearInterval: () => {}
};
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.global = sandbox;
vm.createContext(sandbox);
try {
vm.runInContext(decryptCode, sandbox, { timeout: 5000 });
console.log('✓ 解密函数已加载\n');
} catch (e) {
console.log('⚠ 加载解密函数警告:', e.message);
}

第四步:替换解密函数调用

这是最核心的一步,将所有解密函数调用替换为实际字符串:

function isNodeLiteral(node) {
if (Array.isArray(node)) return node.every(ele => isNodeLiteral(ele));
if (types.isLiteral(node)) return node.value != null;
if (types.isBinaryExpression(node)) return isNodeLiteral(node.left) && isNodeLiteral(node.right);
if (types.isUnaryExpression(node, {"operator": "-"}) || types.isUnaryExpression(node, {"operator": "+"})) {
return isNodeLiteral(node.argument);
}
return false;
}
let decryptSuccess = 0, decryptFail = 0;
traverse(ast, {
CallExpression(path) {
let {callee, arguments: args} = path.node;
if (!types.isIdentifier(callee)) return;
const funcName = callee.name;
// 检查是否是解密函数或其别名
if (funcName !== 'a0_0x4a83' && !aliasMap.has(funcName)) return;
// 检查参数是否都是字面量
if (!isNodeLiteral(args)) return;
try {
const originalCode = generator(path.node, {compact: true}).code;
// 将别名替换为原始函数名
let codeToEval = funcName !== 'a0_0x4a83'? originalCode.replace(funcName, 'a0_0x4a83'): originalCode;
// 在沙箱中执行
let value = vm.runInContext(codeToEval, sandbox, { timeout: 1000 });
// 替换节点
path.replaceWith(types.valueToNode(value));
decryptSuccess++;
} catch (e) {
decryptFail++;
}
}
});
console.log(`解密函数替换: 成功 ${decryptSuccess}, 失败 ${decryptFail}\n`);

第五步:替换对象属性访问

将对象属性访问替换为实际值:

// 混淆代码
obj["OBvQE"] // → "admin"
obj.pQrSt // → "123456"

AST代码实现:

let objLiteralSuccess = 0;
traverse(ast, {
MemberExpression(path) {
const {object, property, computed} = path.node;
if (!types.isIdentifier(object)) return;
const objName = object.name;
if (!objectMap.has(objName)) return;
const props = objectMap.get(objName);
let propName;
if (computed && types.isStringLiteral(property)) {
propName = property.value;
} else if (!computed && types.isIdentifier(property)) {
propName = property.name;
} else {
return;
}
if (!props[propName]) return;
const propInfo = props[propName];
// 只替换字面量,不替换函数
if (propInfo.type === 'literal') {
// 确保不是被调用的
if (path.parentPath && types.isCallExpression(path.parentPath.node) &&
path.parentPath.node.callee === path.node) {
return;
}
path.replaceWith(types.valueToNode(propInfo.value));
objLiteralSuccess++;
}
}
});
console.log(`对象字面量替换: ${objLiteralSuccess} 个\n`);

第六步:替换对象函数调用

对象中的函数包装有两种类型:

1. 函数调用包装

// 定义
wrapper: function(a, b, c) { return a(b, c); }
// 调用
obj.wrapper(myFunc, x, y) // → myFunc(x, y)

2. 二元运算包装

// 定义
eq: function(a, b) { return a === b; }
// 调用
obj.eq(user, "admin") // → user === "admin"

AST代码实现:

let objFuncSuccess = 0;
traverse(ast, {
CallExpression(path) {
const {callee, arguments: args} = path.node;
if (!types.isMemberExpression(callee)) return;
const {object, property, computed} = callee;
if (!types.isIdentifier(object)) return;
const objName = object.name;
if (!objectMap.has(objName)) return;
const props = objectMap.get(objName);
let propName;
if (computed && types.isStringLiteral(property)) {
propName = property.value;
} else if (!computed && types.isIdentifier(property)) {
propName = property.name;
} else {
return;
}
if (!props[propName]) return;
const propInfo = props[propName];
// 函数调用包装:obj.func(fn, a, b) -> fn(a, b)
if (propInfo.type === 'call') {
const { argIndices } = propInfo;
if (args.length > 0) {
const realCallee = args[0];
const realArgs = argIndices.map(idx => args[idx]).filter(a => a !== undefined);
const newCall = types.callExpression(realCallee, realArgs);
path.replaceWith(newCall);
objFuncSuccess++;
}
}
// 二元运算:obj.func(a, b) -> a + b
else if (propInfo.type === 'binary') {
const { operator, leftIdx, rightIdx } = propInfo;
if (args[leftIdx] && args[rightIdx]) {
const newExpr = types.binaryExpression(operator, args[leftIdx], args[rightIdx]);
path.replaceWith(newExpr);
objFuncSuccess++;
}
}
}
});
console.log(`对象函数调用替换: ${objFuncSuccess} 个\n`);

第七步:简化字面量格式

将十六进制数字和Unicode字符串转为可读格式:

// 0x100 → 256
// "\x68\x65\x6c\x6c\x6f" → "hello"
traverse(ast, {
NumericLiteral({node}) {
if (node.extra && /^0[obx]/i.test(node.extra.raw)) {
node.extra = undefined;
}
},
StringLiteral({node}) {
if (node.extra && /\\[ux]/gi.test(node.extra.raw)) {
node.extra = undefined;
}
},
});

解混淆效果对比

混淆前

var _0x3bf0 = a0_0x4a83;
var _0xbm2n = {
"OBvQE": _0x3bf0(0x1a2),
"pQrSt": function(_0x1a2b, _0x3c4d) {
return _0x1a2b === _0x3c4d;
}
};
if (_0xbm2n[_0x3bf0(0x1b3)](_0xbm2n["OBvQE"], "admin")) {
console[_0x3bf0(0x1c4)](_0x3bf0(0x1d5));
}

解混淆后

var _0xbm2n = {
"OBvQE": "admin",
"pQrSt": function(_0x1a2b, _0x3c4d) {
return _0x1a2b === _0x3c4d;
}
};
if ("admin" === "admin") {
console.log("login success");
}

auth接口

auth接口只有一个sign参数加密

进入堆栈搜索sign关键词打断点刷新图片

代码扣下来分析,函数名字俺命名为sign函数

function sign(_0x392c78) {
const _0x2639ff = _0x5354ca;
const _0x18ee40 = {
appId: _0x392c78["aid"],
type: 1,
version: _0x392c78["version"],
pn: _0x392c78.pn,
os: _0x392c78.os,
sdkName: _0x392c78["sdkName"],
timestamp: Math.round(new Date()["getTime"]()),
nonce: Math["round"](new Date()["getTime"]()) + Math["floor"](100000000 * Math["random"]()),
ui: null,
rc: 0,
pc: 0,
ec: 0,
hc: 0,
xc: 0,
dc: 0,
phone: _0x392c78["phone"]
};
let _0x3ca1de = '';
const _0x2a4d77 = Object["keys"](_0x18ee40).sort();
for (let _0x1ee694 = 0; _0x1ee694 < _0x2a4d77["length"]; _0x1ee694++) {
_0x3ca1de += String(_0x2a4d77[_0x1ee694]) + String(_0x18ee40[_0x2a4d77[_0x1ee694]]);
}
return _0x18ee40["sign"] = md5(_0x3ca1de), _0x18ee40;
};

传参_0x392c78经分析是不变化的一个对象

_0x392c78 ={
"obj": "_jg", // 对象名或模块标识,可能是“Jiagu”的缩写
"aid": "aid", // 应用ID或授权标识,用于识别应用
"prefix": "jg_captcha", // 资源或变量前缀,用于命名区分
"version": "2.0.0", // SDK或接口版本号
"sdkName": "***CaptchaSDK", // SDK名称,表明是***验证码SDK
"type": 1, // 验证码类型,数字编码
"pic_cdn": "", // 图片CDN地址,空字符串表示未配置
"typeMap": { // 类型映射表,数字类型对应字符串名称
"0": "basic", // 0表示基础验证码
"1": "clickword" // 1表示点击文字验证码
},
"captchaTypeMap": { // 类型常量映射,方便代码中使用常量名称
"TYPE_BASIC": 0, // 基础验证码常量值为0
"TYPE_CLICK": 1 // 点击验证码常量值为1
},
"noserverImgSrc": "https://cdn.jiagu.com/images/default.jpg", // 验证码加载失败时显示的默认图片地址
"api_server": "https://captcha.jiagu.***.cn", // ***加固验证码API服务器基础地址
"api_auth": "https://captcha.jiagu.***.cn/api/v3/auth", // 验证码认证接口地址,用于获取授权信息
"api_verify": "https://captcha.jiagu.***.cn/api/v3/check", // 验证接口地址,用于提交验证码结果验证
"height": 150, // 验证码图片或组件高度,单位像素
"os": 3, // 操作系统编号,具体含义需参考SDK文档(一般3可能代表Web)
"pn": "com.web.tianyu", // 产品名或包名,标识调用此SDK的项目
"phone": 10000000000 // 手机号码示例,估计登录或者注册接口会获取吧(猜测)
}

这个sign函数就是将固定值对象_0x392c78取值进行生成签名,然后加密明文有时间戳,所以就有了接口的有效性校验。生成签名之后最后赋值给对象返回

function sign(_0x392c78) {
const _0x2639ff = _0x5354ca; // 混淆代码残留
// 构造一个对象,包含多种参数,用于生成签名
const _0x18ee40 = {
appId: _0x392c78["aid"], // 应用ID,从传入参数获取
type: 1, // 固定类型值,这里为1
version: _0x392c78["version"], // 版本号,从传入参数获取
pn: _0x392c78.pn, // 产品名或包名,从传入参数获取
os: _0x392c78.os, // 操作系统标识,从传入参数获取
sdkName: _0x392c78["sdkName"], // SDK名称,从传入参数获取
timestamp: Math.round(new Date()["getTime"]()),// 当前时间戳(毫秒),取整
// 生成一个随机数nonce,先取当前时间戳,后加一个0~1亿的随机整数
nonce: Math["round"](new Date()["getTime"]()) + Math["floor"](100000000 * Math["random"]()),
ui: null, // 未知参数,赋值null
rc: 0, // 计数参数,初始为0
pc: 0, // 计数参数,初始为0
ec: 0, // 计数参数,初始为0
hc: 0, // 计数参数,初始为0
xc: 0, // 计数参数,初始为0
dc: 0, // 计数参数,初始为0
phone: _0x392c78["phone"] // 手机号,从传入参数获取
};
// 用于拼接字符串以计算签名
let _0x3ca1de = '';
// 获取上面对象的所有键名并排序
const _0x2a4d77 = Object["keys"](_0x18ee40).sort();
// 遍历排序后的键,把“键名+键值”拼接成字符串
for (let _0x1ee694 = 0; _0x1ee694 < _0x2a4d77["length"]; _0x1ee694++) {
_0x3ca1de += String(_0x2a4d77[_0x1ee694]) + String(_0x18ee40[_0x2a4d77[_0x1ee694]]);
}
// 对拼接好的字符串进行MD5加密,得到签名,赋值给对象的 sign 属性
_0x18ee40["sign"] = md5(_0x3ca1de);
// 返回这个完整的参数对象(包含签名)
return _0x18ee40;
};

图片下载下来之后发现是乱序图片

底图还原

监听canvas事件

刷新图片,断点断下来了,可以很清晰的看到代码有个decryptImage,_0x5080d6.onload函数扣下来逐行分析

代码执行会操作一些dom元素,把关于dom元素的代码全部删掉

function onloadImg() {
// 设置画布宽度为图片宽度
_0x55ae6b.width = _0x5080d6["width"];
// 设置画布高度为图片高度
_0x55ae6b["height"] = _0x5080d6.height;
// 在画布上绘制原始图片,起点(0,0),宽高为图片宽高
_0x81f4b1["drawImage"](_0x5080d6, 0, 0, _0x5080d6.width, _0x5080d6.height);
// 从图片地址(imgSrc)中提取文件名(不含后缀)
// 先用 "/" 分割字符串,取倒数第一个元素,再用 "." 分割取第一个部分
const _0x251234 = imgSrc["split"]("/")[_0x18d30b["NfPti"](imgSrc["split"]("/")["length"], 1)]["split"](".")[0];
// 对文件名进行解密,得到一个数组或字符串(解密后的数据)
const _0x3e2bfc = decryptImage(_0x251234);
// 计算每个分块的宽度,等于图片宽度除以解密数组长度
const _0x519761 = Math.floor(_0x5080d6["width"] / _0x3e2bfc.length);
// 遍历解密后的数组,调用回调函数,参数为元素值和索引
convertArray(_0x3e2bfc, (_0x5ca950, _0x2f0818) => {
// 计算绘制时的x坐标,等于索引乘以单块宽度
const _0x5d28d3 = _0x41d868["POBxg"](_0x2f0818, _0x519761);
// 单块宽度
const _0x5bedd5 = _0x519761;
// 在画布上绘制图片的一部分
// 参数依次是:图片对象,
// 源图x坐标(_0x5d28d3),0(y坐标),
// 源图宽度(_0x5bedd5),源图高度(图片高度),
// 目标画布x坐标(_0x5ca950 * 单块宽度),0(y坐标),
// 目标画布宽度(_0x5bedd5),目标画布高度(图片高度)
_0x81f4b1["drawImage"](_0x5080d6, _0x5d28d3, 0, _0x5bedd5, _0x5080d6["height"], _0x5ca950 * _0x519761, 0, _0x5bedd5, _0x5080d6["height"]);
// 将画布内容导出为 PNG 格式的 buffer
const buffer = _0x55ae6b.toBuffer('image/png');
// 将 buffer 写入本地文件 'restored.png'
fs.writeFileSync('restored.png', buffer);
// 控制台输出提示信息,表明还原完成
console.log('还原完成: restored.png');
});
};

接下来就是缺啥补啥了。比较重要的两个函数decryptImage函数

function decryptImage(_0x121c70, _0x10dcdd) {
const _0x58b550 = {
uKQaL: "includes",
LRljw(_0x52e5da, _0x58ed7b) {
return _0x52e5da === _0x58ed7b;
}
};
function _0x1c18e2(_0x2a7547, _0x2daf7e) {
// 如果传入的数组有 includes 方法,直接调用 includes 判断
if (_0x2a7547[_0x58b550["uKQaL"]])
return _0x2a7547[_0x58b550["uKQaL"]](_0x2daf7e);
// 否则用 for 循环遍历数组进行比较判断
for (let _0x282371 = 0, _0xca62fe = _0x2a7547["length"]; _0x282371 < _0xca62fe; _0x282371++)
if (_0x58b550["LRljw"](_0x2a7547[_0x282371], _0x2daf7e))
return true;
return false;
}
// 这里用来遍历输入数组 _0x121c70 的元素,并根据条件处理
for (var _0x10dcdd = [], _0x66e9ff = 0; _0x187f8f["uKlOD"](_0x66e9ff, _0x121c70[_0x187f8f.dxGbO]); _0x66e9ff++) {
// 取当前索引元素
let _0x285e49 = _0x121c70[_0x187f8f["TuJhD"]](_0x66e9ff);
// 如果循环索引大于等于32,退出循环
if (_0x187f8f["hGfWD"](32, _0x66e9ff)) break;
// 当 _0x10dcdd 中包含 _0x285e49 - 32 时,_0x285e49 自增
while (_0x1c18e2(_0x10dcdd, _0x187f8f["hMfZz"](_0x285e49, 32))) {
_0x285e49++;
}
// 把 _0x285e49 - 32 的结果推入 _0x10dcdd 数组
_0x10dcdd.push(_0x187f8f.yMCrD(_0x285e49, 32));
}
// 返回处理后的数组
return _0x10dcdd;
};

convertArray函数:

function convertArray(_0x8e4a4c, _0x1a03c8) {
// _0x8e4a4c: 输入的数组
// _0x1a03c8: 回调函数,参数通常是 (元素值, 索引)
// 初始化循环变量 _0x36c299 = 0,_0x1a01d8 是数组长度
// _0x1cd0cb 是新数组,用于存储转换后的结果
for (var _0x36c299 = 0, _0x1a01d8 = _0x8e4a4c["length"], _0x1cd0cb = []; _0x2dac90.GpUbe(_0x36c299, _0x1a01d8); _0x36c299++) {
// _0x2dac90.GpUbe 用于判断循环条件,类似于 (_0x36c299 < _0x1a01d8)
// 对数组的每个元素调用回调函数 _0x1a03c8,传入当前元素和索引
// 将回调函数返回值赋值给新数组对应位置
_0x1cd0cb[_0x36c299] = _0x2dac90["WyKvQ"](_0x1a03c8, _0x8e4a4c[_0x36c299], _0x36c299);
// _0x2dac90.WyKvQ 表示调用函数 _0x1a03c8,传入当前元素和索引
}
// 返回转换后的新数组
return _0x1cd0cb;
};

还原代码分析:
底图是被切成32个垂直切片后打乱顺序的,还原的关键在于:

  1. 从图片URL中提取文件名
  2. 根据文件名计算出切片的正确顺序
  3. 按正确顺序重新拼接图片

环境初始化

// 引入 node-canvas 库,用于在 Node.js 环境中操作 canvas
const { createCanvas, loadImage } = require('canvas');
// 引入文件系统模块,用于保存还原后的图片
const fs = require('fs');
// 模拟浏览器中的 canvas 元素 <canvas width="544" height="284">
// _0x55ae6b 就是 canvas 元素
const _0x55ae6b = createCanvas(544, 284);
// 获取 canvas 的 2D 绑定上下文,用于绑制图像
const _0x81f4b1 = _0x55ae6b.getContext('2d');
// 图片对象,会被 loadImage 异步加载后赋值
_0x5080d6 = null;
// 验证码图片的 URL 地址
const imgSrc = '地址';

主函数

async function main() {
// 异步加载图片,返回 Image 对象
_0x5080d6 = await loadImage(imgSrc);
// 保留 src 属性(注意:node-canvas 中此赋值可能无效,需要直接使用 imgSrc)
_0x5080d6.src = imgSrc;
// 调用图片还原函数
onloadImg();
}

核心解密函数 - decryptImage

这是底图还原的核心算法,根据文件名计算切片顺序:

/**
* 解密图片切片顺序
* @Param {string} _0x121c70 - 图片文件名(不含扩展名)
* @returns {number[]} - 切片顺序数组,长度为32
*
* 算法原理:
* 1. 遍历文件名的每个字符(最多32个)
* 2. 取字符的 ASCII 码对 32 取模
* 3. 如果结果已存在于数组中,则递增直到不重复
* 4. 将结果加入数组
*/
function decryptImage(_0x121c70, _0x10dcdd) {
const _0x58b550 = {
uKQaL: "includes", // 字符串常量
// 严格相等比较
LRljw(_0x52e5da, _0x58ed7b) {
return _0x52e5da === _0x58ed7b;
}
};
/**
* 检查数组是否包含某个值(兼容旧浏览器的 includes 实现)
* @param {array} _0x2a7547 - 要检查的数组
* @param {*} _0x2daf7e - 要查找的值
* @returns {boolean} - 是否包含
*/
function _0x1c18e2(_0x2a7547, _0x2daf7e) {
// 如果数组有 includes 方法,直接使用
if (_0x2a7547[_0x58b550["uKQaL"]])
return _0x2a7547[_0x58b550["uKQaL"]](_0x2daf7e);
// 否则手动遍历查找
for (let _0x282371 = 0, _0xca62fe = _0x2a7547["length"]; _0x282371 < _0xca62fe; _0x282371++)
if (_0x58b550["LRljw"](_0x2a7547[_0x282371], _0x2daf7e))
return true;
return false;
}
// 初始化结果数组
// _0x10dcdd = []
// _0x66e9ff = 0 (循环索引)
for (var _0x10dcdd = [], _0x66e9ff = 0;
_0x187f8f["uKlOD"](_0x66e9ff, _0x121c70[_0x187f8f.dxGbO]); // _0x66e9ff < _0x121c70.length
_0x66e9ff++) {
// 获取当前字符的 ASCII 码
// _0x285e49 = _0x121c70.charCodeAt(_0x66e9ff)
let _0x285e49 = _0x121c70[_0x187f8f["TuJhD"]](_0x66e9ff);
// 如果索引等于 32,跳出循环(最多处理32个字符)
// if (32 === _0x66e9ff) break;
if (_0x187f8f["hGfWD"](32, _0x66e9ff)) break;
// 如果 _0x285e49 % 32 已存在于数组中,则递增 _0x285e49
// while (_0x10dcdd.includes(_0x285e49 % 32)) _0x285e49++;
for (; _0x1c18e2(_0x10dcdd, _0x187f8f["hMfZz"](_0x285e49, 32));)
_0x285e49++;
// 将 _0x285e49 % 32 加入结果数组
// _0x10dcdd.push(_0x285e49 % 32);
_0x10dcdd.push(_0x187f8f.yMCrD(_0x285e49, 32));
}
// 返回切片顺序数组
return _0x10dcdd;
}

数组遍历函数 - convertArray

function convertArray(_0x8e4a4c, _0x1a03c8) {
// 初始化循环变量和结果数组
// _0x36c299 = 0 (索引)
// _0x1a01d8 = _0x8e4a4c.length (数组长度)
// _0x1cd0cb = [] (结果数组)
for (var _0x36c299 = 0, _0x1a01d8 = _0x8e4a4c["length"], _0x1cd0cb = [];
_0x2dac90.GpUbe(_0x36c299, _0x1a01d8); // _0x36c299 < _0x1a01d8
_0x36c299++) {
// 调用回调函数,传入 (数组元素值, 索引)
// _0x1cd0cb[i] = callback(arr[i], i)
_0x1cd0cb[_0x36c299] = _0x2dac90["WyKvQ"](_0x1a03c8, _0x8e4a4c[_0x36c299], _0x36c299);
}
return _0x1cd0cb;
}

图片还原主函数 - onloadImg

function onloadImg() {
// 设置 canvas 尺寸与图片一致
// _0x55ae6b.width = _0x5080d6.width
// _0x55ae6b.height = _0x5080d6.height
_0x55ae6b.width = _0x5080d6["width"],
_0x55ae6b["height"] = _0x5080d6.height,
// 先将原图绘制到 canvas 上(这一步会被后续的切片重绘覆盖)
_0x81f4b1["drawImage"](_0x5080d6, 0, 0, _0x5080d6.width, _0x5080d6.height);
// 从图片 URL 中提取文件名(不含扩展名)
// 例如:从 "https://xxx/e19m5dab9fo9i8g9hc69lj9n72k04399.png"
// 提取出 "e19m5dab9fo9i8g9hc69lj9n72k04399"
// imgSrc.split("/") 按 "/" 分割
// [length - 1] 取最后一个元素(文件名.png)
// .split(".")[0] 去掉扩展名
const _0x251234 = imgSrc["split"]("/")[_0x18d30b["NfPti"](imgSrc["split"]("/")["length"], 1)]["split"](".")[0];
// 调用解密函数,根据文件名计算切片顺序
// 返回一个长度为 32 的数组,如 [5, 17, 25, 13, 21, 4, 1, 2, ...]
const _0x3e2bfc = decryptImage(_0x251234);
// 计算每个切片的宽度
// 图片宽度 / 切片数量 = 544 / 32 = 17
const _0x519761 = Math.floor(_0x5080d6["width"] / _0x3e2bfc.length);
// 遍历切片顺序数组,重新绘制图片
// 回调参数:_0x5ca950 = 数组的值(目标位置),_0x2f0818 = 索引(源位置)
convertArray(_0x3e2bfc, (_0x5ca950, _0x2f0818) => {
// 计算源切片的 X 坐标
// srcX = index * sliceWidth
const _0x5d28d3 = _0x41d868["POBxg"](_0x2f0818, _0x519761);
// 切片宽度
const _0x5bedd5 = _0x519761;
// 核心绘制操作:
// drawImage(源图片, 源X, 源Y, 源宽, 源高, 目标X, 目标Y, 目标宽, 目标高)
// 从原图的第 index 个位置取切片,放到新图的第 value 个位置
// srcX = _0x2f0818 * _0x519761 (索引 * 切片宽度)
// dstX = _0x5ca950 * _0x519761 (值 * 切片宽度)
_0x81f4b1["drawImage"](
_0x5080d6, // 源图片
_0x5d28d3, 0, // 源起点 (srcX, 0)
_0x5bedd5, _0x5080d6["height"], // 源尺寸 (切片宽度, 图片高度)
_0x5ca950 * _0x519761, 0, // 目标起点 (dstX, 0)
_0x5bedd5, _0x5080d6["height"] // 目标尺寸 (切片宽度, 图片高度)
);
});
// 将还原后的图片导出为 PNG 格式
const buffer = _0x55ae6b.toBuffer('image/png');
// 保存到文件
fs.writeFileSync('restored.png', buffer);
console.log('还原完成: restored.png');
}
// 执行主函数
main();

算法流程图

node底图还原遇到了个问题就是,在浏览器环境中,可以通过 img.src = url 来设置图片源,但在 node-canvas 中,loadImage 返回的 Image 对象不支持直接设置 src 属性。用py复现还原底图其实更为方便,后续也是可以转成py的,笔者太懒,不想转换了。

check接口

搜索report参数,断点直接断住了

length就是滑块距离,重点分析report,report就是rarr传进enc函数返回的值

全局搜索rarr,分析代码。

代码就是监听鼠标事件获取轨迹

try {
// 获取鼠标的横坐标 pageX
let _0x3f5faf = _0x3ff692.pageX;
// 获取鼠标的纵坐标 pageY
let _0x4420f9 = _0x3ff692["pageY"];
// 如果在移动端环境,则从 touches[0] 中读取 pageX 和 pageY,并向下取整
_0x4a2e4d("checkMobileEnv")() && (
_0x3f5faf = Math["floor"](_0x3ff692["touches"][0]["pageX"]),
_0x4420f9 = Math["floor"](_0x3ff692.touches[0]["pageY"])
);
// 如果 pos 属性还没有赋值,则赋值为本次事件的起点坐标(减去滑块的左偏移获得鼠标相对于滑块的 X 坐标,Y 坐标用 getBoundingClientRect().y)
!_0xe91a68["pos"] && (_0xe91a68["pos"] = {
x: _0x3f5faf - this["offsetLeft"],
y: this.getBoundingClientRect().y
});
// 如果之前 marginLeft 不存在,则给 rarr[0][0] 对象加上 y 坐标,记录下拖动开始的位置
!_0x511e91 && (_0xe91a68["rarr"][0][0].y = _0x4420f9);
// 给当前拖动对象设置样式:阴影、边框和 loading 背景图
this["style"]["boxShadow"] = "-1px 0px 2px rgba(26,150,82,0.3)";
this["style"]["border"] = "1px #00C95A";
this["style"]["backgroundImage"] = "url(" + _0x4a2e4d("imgSliderLoading") + ")";
// 获取 id 为 "slider-cover" 的元素,并设置其边框颜色
const _0x11fed4 = _0x4a2e4d("getElements")("id", "slider-cover");
_0x11fed4["style"].border = "1px solid #45D887";
// 如果 pos 还没有值,则将 slider-box 的 marginLeft 归零
if (!_0xe91a68["pos"]) {
_0x4a2e4d("getElements")("class", "slider-box")["style"]["marginLeft"] = "0";
} else {
// 计算当前鼠标/手指与初始点击点的横向距离
const _0x3420d0 = _0x3f5faf - _0xe91a68["pos"].x;
// 如果小于 0,则 marginLeft 赋值为 0px(不允许向左拖动)
if (_0x3420d0 < 0) {
_0x4a2e4d("getElements")("class", "slider-box")["style"]["marginLeft"] = `${0}px`;
}
// 如果大于 250,则 marginLeft 赋值为 250px(最大滑动距离)
else if (_0x3420d0 > 250) {
_0x4a2e4d("getElements")("class", "slider-box")["style"].marginLeft = 250 + "px";
}
// 如果是刚刚开始拖动,则 marginLeft 赋值为 0px
else if (_0xe91a68.events[0] === "dragStart") {
_0x4a2e4d("getElements")("class", "slider-box").style["marginLeft"] = 0 + "px";
}
// 否则,将 marginLeft 设置为当前拖动距离
else {
_0x4a2e4d("getElements")("class", "slider-box")["style"].marginLeft = `${_0x3420d0}px`;
}
}
} catch (_0x5d0b9d) {
// 如果 try 里面出错,则打印错误并调用 universalLogCallback 日志上报
console["error"](_0x5d0b9d);
_0x4a2e4d("universalLogCallback")(2015);
}

rarr解决之后直接进入enc函数分析,就是传入轨迹然后跟之前auth接口的返回值进行了各种加密拼接

对轨迹进行的encryptLong加密经分析就是RSA加密,没有魔改,直接套库就行。

滑块识别代码

def detect_slide_distance(bg_url: str, front_url: str) -> int:
"""
使用ddddocr识别滑块缺口位置
Args:
bg_url: 背景图URL
front_url: 滑块图URL
Returns:
滑动距离(px)
"""
# 下载图片
bg_image = requests.get(bg_url).content
front_image = requests.get(front_url).content
det = ddddocr.DdddOcr(det=False, ocr=False,show_ad=False)
result = det.slide_match(front_image, bg_image, simple_target=True)
return result['target'][0]

轨迹模拟代码

轨迹代码只做参考,兄弟们可以去研究一下其他的轨迹,他这个就一个轨迹进行了加密,有可能对轨迹校验比较严格。

def generate_human_track(distance: int, start_y: int = 200, overshoot: bool = True) -> list:
"""
生成拟人轨迹
Args:
distance: 滑动距离(px)
start_y: 起始Y坐标
overshoot: 是否过冲

Returns:
轨迹列表 [{"0": {"t": timestamp, "y": y}}, ...]
"""
track = []
current_x = 0
current_y = start_y
start_time = int(time.time() * 1000)
current_time = start_time
overshoot_distance = random.uniform(3, 8) if overshoot else 0
target = distance + overshoot_distance
velocity = 0
# 起始点
track.append({"0": {"t": current_time, "y": current_y}})
# 前进阶段
while current_x < target:
if current_x < distance * 0.7:
acceleration = random.uniform(1.5, 3)
else:
acceleration = random.uniform(-1, 0.5)
velocity = max(0.5, velocity + acceleration * 0.1)
velocity = min(velocity, 15)
step = velocity * random.uniform(0.8, 1.2)
step = min(step, target - current_x)
current_x += step
current_y += random.uniform(-1.5, 1.5)
current_time += random.randint(10, 20)
track.append({str(len(track)): {"t": current_time, "y": round(current_y, 2)}})
# 回调阶段
if overshoot and current_x > distance:
while current_x > distance:
step = random.uniform(0.5, 2)
step = min(step, current_x - distance)
current_x -= step
current_y += random.uniform(-0.3, 0.3)
current_time += random.randint(20, 40)
track.append({str(len(track)): {"t": current_time, "y": round(current_y, 2)}})
return track

识别结果:

感觉成功率有点低哈,目前还不晓得问题出在哪里,可能是轨迹问题,也有可能还有风控吧(俺没找到),也没有说一定要上并发,毕竟只是练习题,练习练习能出值就行了。

结尾

思考ing...


注:若转载请注明大神论坛来源(本贴地址)与作者信息。

返回顶部