首页
社区
课程
招聘
某御验证码逆向分析
发表于: 5天前 623

某御验证码逆向分析

5天前
623

声明

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

前言

分析网站是官网接口,网址:aHR0cHM6Ly90aWFueXUuMzYwLmNuLyMvZ2xvYmFsL2RldGFpbHMvc2xpZGluZy1wdXp6bGU=

接口分析

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

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

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

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

校验成功返回值

1
2
3
4
5
6
7
8
{
    "error": 0,
    "msg": "成功",
    "data": {
        "result": true,
        "token": "d0dadf3f91b84e5a6a9c0fbaed101033"
    }
}

校验失败返回值

1
2
3
4
5
6
7
8
{
    "error": 0,
    "msg": "成功",
    "data": {
        "result": false,
        "token": "6acc453ec9b0f9e1dea29d8323906dac"
    }
}

成功与失败的区别在于result

反调试分析

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

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

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

解混淆

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

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

解密函数分析

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

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

a0_0x4a83函数分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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: 输出文件

整体解混淆流程如下:

完整流程

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

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

1
2
3
4
5
6
7
8
9
10
var _0xbm2n = {
    "OBvQE": "admin",           // 字符串字面量
    "pQrSt": "123456",          // 字符串字面量
    "uVwXy": function(a, b) {   // 比较函数包装
        return a === b;
    },
    "eFgHi": function(fn, x) {  // 函数调用包装
        return fn(x);
    }
};

对象映射

AST代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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);
    }
  }
});

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

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

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

别名链

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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`);

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

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

沙箱环境

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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);
}

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

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

解密流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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`);

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

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

1
2
3
// 混淆代码
obj["OBvQE"// → "admin"
obj.pQrSt     // → "123456"

AST代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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. 函数调用包装

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

2. 二元运算包装

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

AST代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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字符串转为可读格式:

1
2
// 0x100 → 256
// "\x68\x65\x6c\x6c\x6f" → "hello"
1
2
3
4
5
6
7
8
9
10
11
12
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;
    }
  },
});

解混淆效果对比

效果对比

混淆前

1
2
3
4
5
6
7
8
9
10
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));
}

解混淆后

1
2
3
4
5
6
7
8
9
var _0xbm2n = {
    "OBvQE": "admin",
    "pQrSt": function(_0x1a2b, _0x3c4d) {
        return _0x1a2b === _0x3c4d;
    }
};
if ("admin" === "admin") {
    console.log("login success");
}

auth接口

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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经分析是不变化的一个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
_0x392c78 ={
    "obj": "_jg",                         // 对象名或模块标识,可能是“Jiagu”的缩写
    "aid": "aid",           // 应用ID或授权标识,用于识别应用
    "prefix": "jg_captcha",               // 资源或变量前缀,用于命名区分
    "version": "2.0.0",                   // SDK或接口版本号
    "sdkName": "360CaptchaSDK",           // SDK名称,表明是360验证码SDK
    "type": 1,                           // 验证码类型,数字编码,1代表“clickword”(点击文字验证码)
    "pic_cdn": "",                       // 图片CDN地址,空字符串表示未配置
    "typeMap": {                        // 类型映射表,数字类型对应字符串名称
        "0": "basic",                   // 0表示基础验证码
        "1": "clickword"                // 1表示点击文字验证码
    },
    "captchaTypeMap": {                  // 类型常量映射,方便代码中使用常量名称
        "TYPE_BASIC": 0,                // 基础验证码常量值为0
        "TYPE_CLICK": 1                 // 点击验证码常量值为1
    },
    "noserverImgSrc": "3e1K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6U0k6r3&6Q4x3X3g2B7K9h3q4Y4N6g2)9J5k6h3y4G2L8g2)9J5c8X3W2E0j5h3N6W2M7#2)9J5c8X3c8W2k6X3q4#2L8s2c8Q4x3X3g2B7M7r3M7`."// 验证码加载失败时显示的默认图片地址
    "api_server": "198K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6U0j5i4m8@1j5$3S2S2i4K6u0W2K9X3W2S2k6%4g2Q4x3X3f1K6y4U0m8Q4x3X3g2U0L8R3`.`.",                  // 360加固验证码API服务器基础地址
    "api_auth": "71eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6U0j5i4m8@1j5$3S2S2i4K6u0W2K9X3W2S2k6%4g2Q4x3X3f1K6y4U0m8Q4x3X3g2U0L8W2)9J5c8X3q4H3K9g2)9J5c8Y4j5K6i4K6u0r3j5i4g2@1K9l9`.`.",        // 验证码认证接口地址,用于获取授权信息
    "api_verify": "61eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6U0j5i4m8@1j5$3S2S2i4K6u0W2K9X3W2S2k6%4g2Q4x3X3f1K6y4U0m8Q4x3X3g2U0L8W2)9J5c8X3q4H3K9g2)9J5c8Y4j5K6i4K6u0r3j5$3S2W2j5$3D9`.",     // 验证接口地址,用于提交验证码结果验证
    "height": 150,                       // 验证码图片或组件高度,单位像素
    "os": 3,                            // 操作系统编号,具体含义需参考SDK文档(一般3可能代表Web)
    "pn": "com.web.tianyu",             // 产品名或包名,标识调用此SDK的项目
    "phone": 10000000000                // 手机号码示例,估计登录或者注册接口会获取吧(猜测)
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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元素的代码全部删掉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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. 按正确顺序重新拼接图片

底图还原原理

环境初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
// 引入 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 = '地址';

主函数

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

核心解密函数 - decryptImage

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

decryptImage算法详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
 * 解密图片切片顺序
 * @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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

切片重组过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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 中提取文件名(不含扩展名)
  // 例如:从 "585K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6^5P5s2S2Q4x3V1k6W2x3e0W2E0y4h3c8S2j5U0W2X3L8K6W2A6z5r3M7&6K9r3x3$3z5h3I4B7z5h3^5%4x3X3D9H3y4o6x3&6z5g2)9J5k6i4m8F1k6H3`.`."
  // 提取出 "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,分析代码。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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加密,没有魔改,直接套库就行。

滑块识别代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 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]

轨迹模拟代码

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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

识别结果:

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

结尾

emmm,思考ing...


传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 0
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回