极验滑块JS逆向:用AST还原混淆代码的实战指南
2026/9/15 21:45:29 网站建设 项目流程

搞极验滑块的人,基本都绕不过它那一坨被混淆得亲妈都认不出来的JS。一开始我也试过直接浏览器里打断点,好家伙,变量名全是_0x2e8f3这种十六进制短码,函数套函数,还带一堆废代码,根本没法理清逻辑。后来被逼着去啃AST,才算是找到了正门。

这篇文章是“极验滑块验证码研究与还原”系列的第一篇,先把最基础也最关键的一步讲透:怎么用AST(抽象语法树)把极验那套混淆JS给还原成人能看懂的代码。只有把混淆这一关过了,后面分析加密参数、轨迹生成、风控策略才有戏。本文适合有一定前端基础、想深入JS逆向或者做前端安全研究的朋友,零基础也能跟着步骤跑通,我会把原理和代码都拆开讲。

1. 为什么第一步必须是AST还原

1.1 极验前端JS到底“混”在哪

极验的前端JS不是简单压缩一下就算完事,它是经过一整套工具链处理过的,常见的手段一个不落。首先是字符串处理,所有关键字符串——比如加密算法的key、接口的路径、报错信息——都被抽到一个大数组里,下标还是乱的,调用的时候通过一个解密函数动态取出来。这招的名字在圈里叫“字符串阵列”,刚接触的时候我只想说一句:真有你的。

其次是变量名和函数名混淆。正常人写代码,变量叫timestampchallengecaptchaId,混淆完之后全变成_0x1a2b3c这种无意义短码,而且作用域内复用极其严重,同一段代码里你根本看不出哪个变量是干嘛用的。再配合控制流平坦化,把正常的if/elsefor循环打散成一个巨大的while(true)switch分发器,每一步跳到哪一步由状态变量决定,你想顺着代码逻辑读下去?不存在的。

最后还有一层,动态拼接和自执行函数。代码里大量使用Function构造函数、eval、逗号表达式,一笔一笔地把真正的逻辑藏在运行时才拼出来的字符串里。静态看源码就是一堆垃圾,只有跑起来才露真身。

1.2 正则替换和浏览器调试为什么不行

很多人一开始图省事,想拿正则去匹配替换,比如搜_0x2e8f3这个字符串名,全局替换成有意义的变量名。但这种思路很快会碰壁:第一,正则无法理解嵌套结构,你要精准识别一个函数调用、一个作用域边界,正则搞不定;第二,混淆代码里的名字在多个作用域重复定义,盲目全局替换会把逻辑直接改坏;第三,字符串解密函数的返回值是什么,正则是算不出来的,你得自己写逻辑去模拟。

浏览器调试也是一样。你在Sources面板里给解密函数打上断点,确实能看到返回值,但极验的代码是动态加载、动态拼出来的,断点打多了就乱套,而且你手动跟一个调用堆栈还可以,要是跟几百个变量、几十层函数,人脑根本记不住。调试器适合看单点,不适合做批量还原。

所以结论很明确:混淆是程序化生成的问题,必须用程序化手段去逆。AST正是干这个的——它能把JS源码解析成一棵结构化的语法树,每个节点都有类型、有父子关系,你可以写代码精准定位任意一个节点,替换它、删除它、重构它。这才是正路。

1.3 AST工具链怎么选

Babel全家桶是目前最顺手的一套工具。一个标准的还原流程用到的核心库包括:

作用
@babel/parser把JS源码解析成AST,支持JSX、TS、动态import等语法
@babel/traverse遍历AST节点,可以按类型、按条件查找节点,还能修改
@babel/types提供各种AST节点的构造器和校验器,写转换逻辑必配
@babel/generator把修改后的AST重新生成JS代码,支持压缩/美化选项

有人可能会问,为什么不用esprimaescodegen?它们也解析AST,但生态没有Babel丰富,@babel/traverse的路径(path)机制非常顺手,而且Babel的AST结构在社区资料最多,遇到问题查起来方便。极验这类大厂混淆代码里充满了ES6+语法,@babel/parser对这些语法的兼容性甩其他库几条街。

2. 动手前的准备工作

2.1 拿到目标JS文件

要还原极验的JS,第一步当然是把代码拿到手。打开浏览器开发者工具,切到Network面板,刷新页面,在筛选框里输入gtgeetest之类的关键词,能找到好几个JS文件。极验的滑块验证码现在版本比较多,常见的有fullpage.7.8.2.jsslide.7.8.2.js这些,不同版本的混淆强度略有差异,但核心思路一致。右键保存到本地,存成一个.js文件,比如就叫geetest.js

提醒一句,网上有些公开文章里的JS样本可能已经过期,建议以自己抓到的为准。保存的时候注意选择“Save as”保留原始文件,不要在浏览器里复制粘贴,防止格式被改坏。

2.2 先别急着写代码,观察混淆特征

拿到JS文件之后第一件事不是打开编辑器就开始写还原脚本,而是先快速扫一遍代码,识别出它的混淆套路。我一般会先搜几个关键词:看有没有类似var _0x开头的变量名,说明是obfuscator混淆风格;搜[""]或者[0x开头的数组下标,说明用了字符串阵列;搜while(true)配合switch,说明有控制流平坦化。

这一步看起来随意,其实很关键。因为不同的混淆模式对应不同的AST还原插件,你脑子里先有一个“症状清单”,写插件的时候才知道要处理哪几类节点。比如是字符串阵列,那还原逻辑就是“找解密函数→遍历调用点→替换为真实字符串”。

我自己习惯把观察结果记下来。比如这一版极验代码的特征可能是:解密函数有两个,一个针对单字符串,一个针对数组索引;变量名统一带_0x前缀;有一大段用switch分发器的平坦化代码。记录好之后,后面每写一个插件就能对照打钩。

2.3 搭建还原工程

观察完特征,可以初始化项目了。推荐用Node.js环境,版本建议14以上,我用的是16和18都跑过,差距不大。新建一个目录,执行:

npm init -y npm install @babel/parser @babel/traverse @babel/types @babel/generator --save

然后创建一个restore.js文件,先搭一个雏形——读取文件、解析AST、然后原样输出,验证整条链路通不通:

const fs = require('fs'); const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generator = require('@babel/generator').default; const t = require('@babel/types'); const code = fs.readFileSync('geetest.js', 'utf-8'); const ast = parser.parse(code, { sourceType: 'script', // 极验JS文件一般不是module,用script allowReturnOutsideFunction: true, }); // 这里先什么都不干,直接生成一遍 const output = generator(ast).code; fs.writeFileSync('geetest.restore.js', output);

把这段代码跑一遍,如果生成的文件大小和原文件差不多,说明解析没有报错,管道通畅。这一步是打地基,地基不稳后面全白搭。

3. 实操:一步步拆解还原过程

3.1 字符串解密与替换

极验混淆代码里,字符串解密是最内层的一道工序,也是优先级最高的一道。为什么?因为后面的控制流平坦化还原、变量名还原,很多逻辑都要依赖字符串内容才能判断,比如switch分发的状态变量名、某个函数的功能命名,都要看字符串才能确定。顺序对了,事半功倍;顺序反了,会把自己绕晕。

解密函数的调用模式一般是这样的(简化示意):

var _0xabc = ["log", "hello", "world"]; function _0xdec(a, b) { var c = _0xabc; return c[a - b]; } console[_0xdec(1, 0)](_0xdec(2, 0));

这里_0xdec(1,0)返回"log"_0xdec(2,0)返回"world"。我的策略是:写一个插件,遍历所有CallExpression节点,如果它调用的函数是解密函数,就实参计算一下,把结果替换成一个StringLiteral节点。

但这里有个技术难点:怎么确定哪个函数是解密函数?不能写死函数名,因为每次抓到的文件名字都不一样。我的经验是:解密函数的特点非常明显——它的函数体里只有一个return,且返回表达式里有对某个大数组的引用,函数参数参与了索引运算。可以用一个启发式规则去识别。

下面这个插件,会在遍历时收集所有函数节点,然后单独分析哪些候选是解密函数:

function findStringDecoders(ast) { const decoders = {}; traverse(ast, { FunctionDeclaration(path) { const node = path.node; // 函数体只有一个return语句,且有调用BinaryExpression做索引 // 满足这些特征的,标记为候选解密函数 if (node.body.body.length === 1 && t.isReturnStatement(node.body.body[0])) { decoders[node.id.name] = path; } } }); return decoders; }

这只是个粗糙的筛选,实际写的时候还需要更细的特征判断,比如形参数量、返回值类型等。识别出来之后,下一步就是遍历所有调用,用实参计算真实字符串并替换:

traverse(ast, { CallExpression(path) { const callee = path.node.callee; // 检查函数名是否是解密函数 if (t.isIdentifier(callee) && decoders[callee.name]) { // 这里需要根据解密函数的实现来计算返回值 // 简单场景:实参是两个数字字面量,做减法后取数组下标 const args = path.node.arguments; if (args.length === 2 && t.isNumericLiteral(args[0]) && t.isNumericLiteral(args[1])) { const idx = args[0].value - args[1].value; const str = stringArray[idx]; if (str !== undefined) { path.replaceWith(t.stringLiteral(str)); } } } } });

这里的stringArray是在前面分析的时候提取出来的数组内容。需要注意:极验的真实解密函数可能不是简单的减两个实参,可能还有base64解码、异或运算、AES解密等步骤,所以decoders的识别和计算逻辑要按实际代码动态调整。我先用简化模式把整体流程走通,再慢慢完善计算分支。

字符串替换完成后,重新generator生成代码,你会发现代码可读性一下子提高不少,至少那些console["log"]变成了console["log"],也就是console.log。这一阶段能挖出来的信息量非常大,很多接口路径和加密原语都会裸露出来。

3.2 变量名和函数名可读化

字符串被还原之后,代码里依然全是_0x1a2b3c这种变量名。虽然逻辑上不影响运行,但你要阅读它,就像在看天书。还原变量名的目标不是让名字变成它混淆前的原始命名——那基本不可能拿到——而是让名字有区分度、有可读性,方便自己分析。

最简单的做法是:遍历所有Identifier节点,把混淆风格的名字批量替换成var_0var_1这种顺序编号,或者按作用域分别编号scope_var_0。这个方案实现快,但帮助有限,因为var_0var_1还是没有任何含义,该看不懂还是看不懂。

进阶一点的做法,是根据上下文推断变量用途:比如这个变量被传入一个加密函数作为参数,可以给它加个encrypt_arg前缀;如果它出现在new Date()附近,说明它和时间有关,命名成time_xxx。这种推断需要先做一轮数据流分析,工作量不小,但对于理解极验这种大型混淆代码,性价比很高。

我常用的中间方案是:先做纯前缀替换,把混淆命名改掉,同时保留一个“可疑变量”提示符,后面分析到具体算法的时候再重命名。示例如下:

let renameCounter = 0; const nameMap = {}; traverse(ast, { Identifier(path) { const name = path.node.name; // 只处理混淆风格的名字 if (name.startsWith('_0x')) { if (!nameMap[name]) { nameMap[name] = `var_${renameCounter++}`; } path.node.name = nameMap[name]; } } });

这里有个隐藏的坑:函数声明和形参的作用域问题。如果两个函数里各自声明了同名变量_0x1234,但因为它们不在同一个作用域,不应该被重命名成同一个新名字。上面这段代码简单粗暴地用全局nameMap,会把它们错误地合并成一个名字,导致后面分析逻辑错乱。所以真正要做的时候,需要遍历到Function节点时新建一个局部映射表,子作用域沿着父作用域链查找,不能一个全局nameMap打天下。

具体做法是:

traverse(ast, { Function(path) { // 每个函数进入时建立一个新的重命名映射,父级映射保留在闭包链上 const localMap = Object.create(scopeMaps[scopeMaps.length - 1]); scopeMaps.push(localMap); path.traverse({ Identifier(p) { if (p.node.name.startsWith('_0x')) { if (!localMap[p.node.name]) { localMap[p.node.name] = `var_${renameCounter++}`; } p.node.name = localMap[p.node.name]; } } }); scopeMaps.pop(); } });

这一轮做完,代码里的标识符不再是一堆毫无规律的十六进制短码了,你可以开始人肉阅读那些真正重要的算法片段。

3.3 控制流平坦化还原

控制流平坦化是极验混淆里最让人头疼的部分,它的典型结构是一个while(true)外壳,内部一个switch分发器,根据状态变量决定下一段执行哪个case。代码从表面看是一条笔直的线,实际运行顺序被打得粉碎。还原它的思路是把“状态转移表”提取出来,重建原始的线性执行顺序。

我先描述一个简化模型。有一段被平坦化的代码,特征如下:

var state = 0; while (true) { switch (state) { case 0: // 原始代码块A state = 2; continue; case 2: // 原始代码块B state = 5; continue; case 5: // 原始代码块C state = -1; continue; } break; }

还原方法就是:从初始state=0开始,顺着每个case末尾对state的赋值,把case按执行顺序链起来,然后把while(true)壳子去掉,把各个case的代码块按顺序拼接成线性代码。

AST层面怎么实操呢?我的思路是分四步:

第一步,找到最外层的WhileStatement,确认内部是不是一个SwitchStatement,且while判断条件恒定是true。如果条件不满足,说明这不是我们要处理的平坦化结构,直接跳过。

第二步,遍历所有SwitchCase。每个case的consequent里,最后一定会有一个对状态变量的赋值语句,然后一个continue。我们需要读取这个赋值,得到下一跳的状态值。

第三步,把所有case的代码块和状态转移关系存到一个映射表里。状态值作为key,代码块作为value,再从初始状态开始,不断查询下一跳,按照执行顺序把代码块排列出来。

第四步,把原WhileStatement节点替换成排好序的代码块序列(比如用一个BlockStatement包含它们)。

核心代码结构大致是这样:

traverse(ast, { WhileStatement(path) { const node = path.node; const testIsTrue = t.isBooleanLiteral(node.test) && node.test.value === true; const switchNode = node.body.body[0]; if (!testIsTrue || !t.isSwitchStatement(switchNode)) return; const stateBlocks = new Map(); let initialState = null; for (const switchCase of switchNode.cases) { // switchCase.test的值就是state值 const stateVal = switchCase.test.value; const blockNodes = []; let nextState = null; for (const stmt of switchCase.consequent) { if (t.isContinueStatement(stmt)) continue; // 如果遇到赋值语句,识别下一跳状态 if (t.isExpressionStatement(stmt) && t.isAssignmentExpression(stmt.expression)) { const assign = stmt.expression; if (t.isNumericLiteral(assign.right)) { nextState = assign.right.value; continue; } } blockNodes.push(stmt); } stateBlocks.set(stateVal, { body: blockNodes, next: nextState }); } // 顺着链子把代码块排出来 const reordered = []; let currentState = initialState; while (currentState !== null && stateBlocks.has(currentState)) { const block = stateBlocks.get(currentState); reordered.push(...block.body); currentState = block.next; } path.replaceWithMultiple(reordered); } });

实际情况比这个复杂得多:极验的状态变量可能做了别名引用,状态值不一定是数字字面量,可能是表达式;还会插入大量垃圾分支,这些分支永远执行不到,但在静态代码里看起来是“有效”的。还原时需要做更多的判断,比如顺着可达路径遍历,丢弃那些不可达的死分支。

我做的第一个平坦化插件非常粗暴,跑完后代码体积没减小多少,但最关键的是逻辑链条通了。后续配合手工修改,定位加密参数生成函数,基本就够用了。

3.4 清理冗余与输出美化

前面几步做完,整个代码里还会残留大量没用的垃圾节点。比如被还原成字符串字面量之后,原本解密函数已经没人调用了,但函数本身和它的字符串数组还留在代码里;再比如平坦化处理后的while(true)残留、无用变量声明、跳到空语句的if分支等。

清理这一步,我一般用两招。第一招是“死代码删除”——遍历AST,找出那些不可达的语句块,直接path.remove()。配合前面控制流平坦化的分析,很多不可达分支都能顺藤摸瓜找到。第二招是“未引用函数删除”——先收集所有被引用的函数名集合,再删除不在集合里的函数定义,这样能减掉一大截体积,也减少阅读干扰。

不过这里有个注意点:极验的部分代码是通过字符串拼接、Function构造函数动态生成的,静态分析里看不到引用,但不代表它没用。所以删除未引用函数之前,最好先等一轮完整分析结束,确认哪些是真正的死代码,再动手。别为了省事把所有看着没引用的函数都删了,结果把动态执行的功能删没了。

太简单的美化环节也别忽略。@babel/generator支持很多输出选项,比如compact: falsejsescOptioncomments: false。我习惯这样配置:

const output = generator(ast, { comments: false, compact: false, jsescOption: { minimal: true // 让字符串尽量用原字符输出 } }).code;

这样生成的代码是展开格式的,变量名还是有缩减感,但不影响阅读。如果要更漂亮,可以再接一层prettier格式化,我个人一般不用,因为Babel生成的缩进已经足够。

整个还原流程走到这一步,代码已经从“加密文件”变成了“可读工程”。你可以在还原后的文件里搜索gettypeget.phpchallenge这些关键词,找到极验核心的加解密和请求逻辑位置,为下一步分析滑块参数做准备。

4. 常见问题与排查技巧实录

4.1 还原后代码一运行就报错

最常见的情况是,字符串替换阶段把动态执行需要的字符串也替换成了字面量,但字符串本身是代码片段,运行时被eval或者Function使用,替换成字面量之后语法反而错了。这种情况报错信息会很怪,例如Unexpected token出现在生成代码的某个字符串字面量处。

排查思路是先定位到报错行,看看这行代码在还原前是什么样子。如果是eval("some code")这种,在替换字符串时就要加白名单——字符串调用者是evalFunction时,跳过不替换。或者替换完再做一轮语法检查,发现有问题的节点单独回滚。

4.2 变量名重命名后作用域串了

这个问题我在前面提过,是全局映射表导致的。当你发现还原后的代码在某个函数内引用了不存在的变量,大概率是重命名时把不同作用域的同名变量合并了。解决办法就是作用域隔离——每个函数进来都新建一层映射表,查找名字时看当前函数有没有定义,没有再去父级找。Babel的path.scope其实已经帮你做好了作用域管理,推荐使用path.scope.rename接口做重命名,它会自动处理作用域冲突,比自己维护映射表靠谱得多。

4.3 控制流平坦化还原后逻辑完全对不上

这种情况一般出现在:状态变量的值不是数字字面量,而是通过某个变量间接赋值的。比如state = next_statenext_state又是从数组里取出来的。直接比较.value根本取不到,需要先解析一轮变量追踪,把所有间接引用全部解算成常量。

追踪的时候可以用path.evaluate()这个API,@babel/traverse会尝试在静态分析阶段把变量的值计算出来。能用它解决的问题,别自己手动去模拟执行。但要注意,如果表达式里有函数调用,evaluate()通常会返回confident: false,这时候就需要你手动处理具体函数了。

4.4 极验混淆的“度”是动态变化的

不同版本、不同部署环境下,极验的混淆程度不一样。有的版本字符串解密函数巨复杂,有的版本干脆没有控制流平坦化。所以没有一套插件能通吃所有版本,正确思路是:先观察、再针对性写插件、最后迭代精修。整个过程用一个字段控制开关,哪一步该跑,哪一步不跑,灵活调节。

我一般在项目里维护一个配置文件,记录当前目标版本的混淆特征。比如:

特征当前目标版本
字符串阵列存在,解密函数为_0xdec风格
控制流平坦化存在,约 80 个 case
标识符混淆_0x前缀
动态执行少量eval,主要用Function

每次还原完一轮,更新一次配置,免得下次拿到新版本忘记特征。

4.5 版本管理不能省

还原过程中会反复修改代码,很容易把代码越改越乱。我强烈建议每完成一个大步骤,就把还原后的文件和对应插件脚本存一个版本。这样万一后面哪一步写错了,可以直接回滚到上一个正确版本,重写这一步逻辑,而不是从头再来。我用Git来管这些中间产物,commit信息就写“字符串还原完成”“平坦化处理完成”这种,追踪起来非常方便。

5. 写在最后:两个实操中反复验证的经验

折腾极验代码这段时间,我最深的体会是:AST还原不是一次性跑完就结束的“魔法”。真正实用的流程是“多次迭代、逐层递减”的——字符串还原一遍、平坦化一遍、清理一遍,然后又发现有新的混淆模式被暴露出来,再写对应的插件处理。这个过程很像剥洋葱,你不能指望第一刀就把核心逻辑完整露出来,耐心比技巧重要。

另外再分享一个小技巧,每次运行还原脚本后,都会生成一个restore.log日志,记录每一类节点被处理的数量。比如“替换字符串调用:342处”“重命名变量:1289个”“删除死代码:56个”。这些数字能帮你判断当前版本的混淆有多重,也能在下一次跑的时候对比处理效果有没有提升。有了这个日志,定位问题会快很多。

最后提一句:AST还原与其他代码分析手段配合,可以用来理解前端安全机制、分析加密算法、做兼容性修复等合法研究。请确保在合规范围内使用这些技术,目标站点或代码的授权确认好再动手,这既是对自己负责,也是对这个技术方向长期健康发展的负责。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询