极验4ast解混淆流程

极验4 JS混淆还原技术文档

本文档详细记录极验4代验证码JavaScript混淆代码的还原过程与原理分析

目录

一、背景介绍

1.1 极验验证码概述

极验(Geetest) 是国内知名的行为式验证服务商,由武汉极意网络科技有限公司运营。自2012年上线以来,极验已成为国内使用最广泛的验证码解决方案之一。

核心特点
  • 行为验证:通过分析用户滑动、点击等行为轨迹判断是否为真人
  • 无缝验证:无感模式下验证过程对用户透明
  • 多形态支持:支持滑块、文字点选、图标点选、短信验证等多种形式

1.2 极验4代与前三代的区别

版本 核心验证方式 安全等级 代码混淆程度
极验1代 图片滑块 ★★☆☆☆ 简单混淆
极验2代 文字点选 ★★★☆☆ 中等混淆
极验3代 行为验证 ★★★★☆ 较强混淆
极验4代 深度学习+行为分析 ★★★★★ 高度混淆
不同的是极验4取消了3代中的滑动轨迹校验,直接对结果进行校验。

1.3 为什么需要还原JS混淆

  • 分析加密逻辑
  • 自动化验证码识别
  • 安全研究与学习

1.4 文档目标与适用范围

  • 适用版本:极验4代
  • 学习前提:JavaScript基础、AST了解
  • 工具环境:
工具 用途
AST Explorer 在线AST分析
Chrome DevTools 动态调试
VSCode 代码编辑
Node.js 脚本执行

2.1 变量名混淆

复制代码
// 混淆前: function(setting, instance, config)
// 混淆后: function(_ᕺᖘᖄᖘ, _ᖃᕵᖃᕺ, _ᖄᕹᕷᕷ)
使用Unicode稀有字符(CJK兼容区)作为变量名。

2.2 字符串加密(最核心)

  • 核心字符串使用Unicode编码
  • 动态字符串拼接
  • 数组下标访问
javascript 复制代码
var _ᕷᖂᕾᖈ = _ᖆᖉᕴᕹ.$_CV    // _ᕷᖂᕾᖈ 是一个解密函数
  , _ᖈᖉᕾᕾ = ["$_DBIFP"].concat(_ᕷᖂᕾᖈ)
  , _ᕺᕺᕺᖙ = _ᖈᖉᕾᕾ[1];      // _ᕺᕺᕺᖙ = "default"
...
  • 工作原理:
    _ᖆᖉᕴᕹ.$_CV 是字符串解密函数,参数是数字
    _ᕺᕺᕺᖙ(171) 解密后得到 "arrayToHex"
    解密后的字符串用于动态属性访问.

关键解密函数:_ᖆᖉᕴᕹ.$_CV 是极验4的核心字符串解密函数,通过数字参数返回对应字符串。

2.3 控制流混淆

  • 条件分支混淆
  • 循环结构重组
  • try-catch流程控制
javascript 复制代码
function _ᖀᖂᕶᕵ(_ᕺᖘᖄᖘ) {
   var _ᖃᕵᖃᕺ = _ᖆᖉᕴᕹ.$_Dy()[2][4];  // 初始化状态
   for (; _ᖃᕵᖃᕺ !== _ᖆᖉᕴᕹ.$_Dy()[0][3]; ) {  // 终止条件
       switch (_ᖃᕵᖃᕺ) {
       case _ᖆᖉᕴᕹ.$_Dy()[2][4]:
           return _ᕺᖘᖄᖘ && _ᕺᖘᖄᖘ[_ᕺᕺᕺᖙ(10)] ? _ᕺᖘᖄᖘ : {
               default: _ᕺᖘᖄᖘ
           }
       }
   }
}

三、还原准备工作

3.1 开发环境搭建

bash 复制代码
# Node.js环境
node --version

# 必要依赖
npm install @babel/parser @babel/traverse @babel/types @babel/generator

3.2 获取混淆JS文件

  • 浏览器开发者工具定位
  • 网络请求抓取
  • 动态加载分析

四、AST解析核心步骤

4.1 第一步:解析与初始化

javascript 复制代码
const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const path = require('path');

const baseDir = __dirname;
js_code = fs.readFileSync(path.join(baseDir, 'gcaptcha4.js'), 'utf-8')

const ast = parser.parse(js_code, {
    tokens: true,      // 必须:生成 tokens
    sourceType: 'module',
    ranges: true,       // 保留位置范围
    locations: true     // 保留行列号
});

4.2 第二步:定位核心类/对象

  • 识别全局变量声明

  • 查找特征性标识符

    分析源码,发现加密自执行函数中,存在大量类似结构:

javascript 复制代码
var _ᕷᖂᕾᖈ = _ᖆᖉᕴᕹ.$_CV
, _ᖈᖉᕾᕾ = ["$_DBIFP"].concat(_ᕷᖂᕾᖈ)
, _ᕺᕺᕺᖙ = _ᖈᖉᕾᕾ[1];

为了方便阅读,改写成
var a= _ᖆᖉᕴᕹ.$_CV				// _ᖆᖉᕴᕹ.$_CV就是解密函数,重要
, b= ["$_DBIFP"].concat(a)     //拼接数组,
, c= _ᖈᖉᕾᕾ[1];					//调用数组中索引1的函数,其实就是调用a函数,即 _ᖆᖉᕴᕹ.$_CV		 解密函数
...
//后面的字符串加密,全部调用c(1),C(100),C(171)

我们去掉自执行函数,只保留前面的解密函数,然后写一个for循环测试一下

javascript 复制代码
//这里是解密函数,内容太多不再展示,网站拷贝即可。
//写一个for循环,打印前200个解密字符串
for (var a = 0; a < 200; a++) {
    console.log(`_ᕺᕺᕺᖙ(${a})-->${_ᕺᕺᕺᖙ(a)}`);
}

本地和网页的解密结果一致

在网页控制台中断点,执行相同的代码,发现解密结果相同。

4.3 第三步:提取解密函数

  • 识别解密函数模式
    通过上一步,已经定位到解密函数是这几个
  • 收集函数调用关系
    后面解密全部采用类似结构:
javascript 复制代码
var _ᕷᖂᕾᖈ = _ᖆᖉᕴᕹ.$_CV
              , _ᖈᖉᕾᕾ = ["$_DBIFP"].concat(_ᕷᖂᕾᖈ)
              , _ᕺᕺᕺᖙ = _ᖈᖉᕾᕾ[1];

4.4 第四步:执行解密替换

  • 模拟函数执行环境
  • 计算解密结果
  • 替换调用为常量

五、开始写还原代码

通过上面分析,总结一下步骤:

1.收集解密函数,并自执行。

2.通过遍历代码块,找到对应解密模式,即

javascript 复制代码
 var a = decrypt
 ,b = ['字符串拼接'] .concat(a)
 ,c = b[1]

3.分析调用特征 ,即

javascript 复制代码
c(1),c(2),c(3) 或者a(1),a(2),a(3)

4.作用域绑定处理

  • scope.getBinding() 获取变量绑定
  • 追踪引用路径 referencePaths
  • 处理嵌套作用域

先导入相关包,并且读取混淆代码

javascript 复制代码
const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;
const path = require('path');

// 获取当前脚本所在目录的绝对路径
const baseDir = __dirname;

// 读取源文件(使用绝对路径)
js_code = fs.readFileSync(path.join(baseDir, 'gcaptcha4.js'), 'utf-8')

const ast = parser.parse(js_code, {
    tokens: true,      // 必须:生成 tokens
    sourceType: 'module',
    ranges: true,       // 保留位置范围
    locations: true     // 保留行列号
});

然后收集解密函数,通过上图ast分析,body里面一共有7个代码块,其中前5个都是解密函数

所以先写一个函数,直接收集前5个代码块,并且自执行。

有一点需要注意,后面解密函数赋值,全部是赋值的 _ᖆᖉᕴᕹ.$_CV ,即第3个代码块的函数名。为了以后实现自动提取,在函数内部定义一个变量接收这个CV的函数名。

代码尽量写详细

javascript 复制代码
1.导包
2.读取混淆代码,初始化ast
3.收集解密函数名
count = 0;//计数器
global._g_vars = {};// 全局变量存储
function collect_decrypt_name(ast){
	var code = generator(ast.program.body[2].expression.left).code;
    console.log('收集的解密函数名:', code);
    // 从 "_xxx.$_CV" 提取类名 "_xxx"
    global._g_vars.clazz_name = code.split('.')[0];
    return code;
}
4.构建并执行解密代码
function eval_decrypt_code(ast){
	var body = ast.program.body.slice(0, 5);
    var newAst = {
        type: 'File',
        program: {
            type: 'Program',
            body: body
        }
    };
    var eval_code = generator(newAst).code;
		eval(eval_code)   ;
    console.log('初始化代码执行完成');
    // 把动态类名存到全局
    let clazz_name = global._g_vars.clazz_name;
    global._g_vars.clazz = eval(clazz_name);
};
5.遍历ast,找到符合该结构的代码 ,这里我们分析下ast结构
//var a = w,b=['a'].concat(a),c=b[1]    并且w的值是等于之前收集到的解密函数decrypt_name
var _ᕷᖂᕾᖈ = _ᖆᖉᕴᕹ.$_CV
, _ᖈᖉᕾᕾ = ["$_DBIFP"].concat(_ᕷᖂᕾᖈ)
, _ᕺᕺᕺᖙ = _ᖈᖉᕾᕾ[1];

下面用网站分析一下代码结构

这个变量声明里面,有三个变量声明,并且类型都是VariableDeclarator,,类型是var

第一个变量声明是一个调用对象的形式,且第一个变量声明的值,刚好是我们上一步收集到的解密函数decrypt_name

其实这几个条件就足够了

写个代码测试一下

javascript 复制代码
//接上面
function find_targets(ast, decrypt_name) {
    let targets = [];

    traverse(ast, {
        VariableDeclaration(p) {
            let decls = p.node.declarations;

            // 必须有3个变量
            if (decls.length != 3) return;

            // 检查第一个变量是否是解密函数
            if (!decls[0].init) return;
            if (decls[0].init.type != 'MemberExpression') return;

            // 比较函数名
            let scope_name = generator(decls[0].init).code;
            if (scope_name != decrypt_name) return;

            // 收集目标
            targets.push({
                path: p,
                decls: decls
            });
        }
    });

    console.log('找到目标数量:', targets.length);
    return targets;
}

全部是符合我们条件的格式

下一步就需要执行这些代码,执行后,获取绑定的对象,然后进行解密。

javascript 复制代码
function process_targets(targets) {
    targets.forEach(function(item) {
        let decls = item.decls;
        let itemPath = item.path;

        // 4.1 先 eval 这行代码,把变量注册到全局
        let var_code = generator(itemPath.node).code;
        // 把动态类名替换成全局变量
        let clazz_name = global._g_vars.clazz_name;
        var_code = var_code.replace(new RegExp(clazz_name, 'g'), '_g_vars.clazz');
        eval(var_code.replace(/^var\s*/, ''));


        // 4.2 获取两个解密函数的名字
        let first_name = decls[0].id.name;
        let second_name = decls[2].id.name;

        // 4.3 获取绑定
        let first_bind = itemPath.scope.getBinding(first_name);
        let second_bind = itemPath.scope.getBinding(second_name);

        // 4.4 替换第一个函数的调用
        if (first_bind) {
            first_bind.referencePaths.forEach(function(p) {
                if (!p.parentPath) return;
                if (p.parentPath.node.type != 'CallExpression') return;
                if (p.parentPath.node.arguments.length != 1) return;
                if (p.parentPath.node.arguments[0].type != 'NumericLiteral') return;

                let val = eval(p.parentPath + '');
                console.log(p.parentPath+'','-->',val)
                count++;
                p.parentPath.replaceInline(t.valueToNode(val));
            });
        }

        // 4.5 替换第二个函数的调用
        if (second_bind) {
            second_bind.referencePaths.forEach(function(p) {
                if (!p.parentPath) return;
                if (p.parentPath.node.type != 'CallExpression') return;
                if (p.parentPath.node.arguments.length != 1) return;
                if (p.parentPath.node.arguments[0].type != 'NumericLiteral') return;

                let val = eval(p.parentPath + '');
                console.log(p.parentPath+'','-->',val)
                count++;
                p.parentPath.replaceInline(t.valueToNode(val));
            });
        }
    });
}

这里解释下为什么替换两个解密函数,字符串混淆替换逻辑,以及为什么定义全局变量。

之前遍历ast收集的以下结构的代码

javascript 复制代码
var a = w, //   w的值是等于之前收集到的解密函数decrypt_n
b=['a'].concat(a),
c=b[1]
混淆后的代码,解密有两种
1:a(1)
2: c(1)
效果都一样,都是直接调用w这个解密函数。所以需要找到两个解密函数后,每个都执行一次替换逻辑

替换逻辑:

混淆后的代码都是这样:

javascript 复制代码
_ᕺᕺᕺᖙ(166)-->ToModbusCRC16
_ᕺᕺᕺᖙ(167)-->.
_ᕺᕺᕺᖙ(168)-->tha
_ᕺᕺᕺᖙ(169)-->glg
_ᕺᕺᕺᖙ(170)-->bod
_ᕺᕺᕺᖙ(171)-->arrayToHex
 _ᕺᕺᕺᖙ(172)-->fil
_ᕺᕺᕺᖙ(173)-->Netscape
 _ᕺᕺᕺᖙ(174)-->swa
_ᕺᕺᕺᖙ(175)-->mal
_ᕺᕺᕺᖙ(176)-->$_JN
_ᕺᕺᕺᖙ(177)-->language
_ᕺᕺᕺᖙ(178)-->ToCRC16
_ᕺᕺᕺᖙ(179)-->charCodeAt
_ᕺᕺᕺᖙ(180)-->CRC
_ᕺᕺᕺᖙ(181)-->getBrowserLanguage
 _ᕺᕺᕺᖙ(182)-->zh

ast结构如下:

1.type=CallExpression
2.arguments.length1

3.arguments参数type===NumericLiteral

定义全局变量global._g_vars 是因为每个函数执行的作用域不同。

第一次eval(eval_code)这里,要将执行后的函数取出来, 存到全局变量中,这样在后续初始化、找到解密函数声明的时候才可以正常调用。

实际全局变量存储的内容可以打印看下,就是解密函数本身。

最后

javascript 复制代码
let result = main(ast);
fs.writeFileSync(path.join(baseDir, 'step1_decode.js'), result);
console.log('global._g_vars:',global._g_vars)
console.log('完成,已生成 step1_decode.js');

成功解混淆。

解混淆前后对比:

将解混淆后的代码,替换到网站js文件测试一下。可以正常获取验证码以及校验。可以快速定位使用了ase-cbc加密,并且iv是0000000000000000,方便后续分析。

后续会继续进行w值纯算分析和w值参与计算的动态值自动提取

附全家桶通过率(滑块、消消乐、无感、五子棋),点选和svg需要接码,不做展示


声明:本文档仅供学习研究使用,请勿用于任何商业或非法用途。

相关推荐
Mr.E52 小时前
odoo18 关闭搜索框点击自动弹出下拉框
开发语言·前端·javascript·odoo·owl·odoo18
LCG元10 小时前
STM32实战:基于STM32F103的Bootloader设计与IAP在线升级
javascript·stm32·嵌入式硬件
前端一小卒12 小时前
前端工程师的全栈焦虑,我用 60 天治好了
前端·javascript·后端
coderyi13 小时前
LLM Agent 浅析
前端·javascript·人工智能
我叫黑大帅13 小时前
TypeScript 6.0 弃用选项错误 TS5101 解决方法
javascript·后端·面试
科雷软件测试13 小时前
使用python+Midscene.js AI驱动打造企业级WEB自动化解决方案
前端·javascript·python
We་ct14 小时前
LeetCode 120. 三角形最小路径和:动态规划详解
前端·javascript·算法·leetcode·typescript·动态规划
changshuaihua00116 小时前
React 入门
前端·javascript·react.js
掘金安东尼17 小时前
本周前端与 AI 技术情报|前端下一步 #462
前端·javascript·面试