极验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需要接码,不做展示


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