这活儿我干了三年多,前后手撕过不少前端混淆代码,极验滑块算是绕不开的一座山。网上讲极验验证码的文章不少,但大多只给结论不给过程,要么上来就甩一段“拿来就能用”的代码,要么讲到AST就含含糊糊带过去。这篇是“极验滑块验证码破解与研究”系列第一篇,先把最关键的地基打好——AST还原混淆JS。说白了,极验前端JS被重度混淆过,不先把代码还原成人类能看懂的形态,后面分析加密参数、模拟轨迹、构造请求都是空中楼阁。这篇文章适合三类人:被极验混淆代码劝退的爬虫工程师、想做JS代码保护的前端开发、以及对AST静态分析感兴趣的安全研究员。我会从混淆特征入手,一步步拆解AST还原的思路和实操流程,全程代码验证过,可以直接照做。
1. 项目背景与整体思路
1.1 为什么研究极验滑块首先要做AST还原
先说说极验滑块这个场景的痛点。极验的验证码服务经历过v2、v3、v4多次迭代,目前市面上存量最大、残留最多的就是v3和v4。不管是哪种版本,前端都会加载一个体积不小的JS文件,里面暗藏着验证码初始化、用户行为采集、加密参数生成等一系列逻辑。问题在于,极验的这些JS不是给你读的,它们经过了完整的混淆流水线:变量名替换、字符串加密、控制流平坦化、常量加密、对象key动态计算,层层叠叠包在一起。
直接打开看源码的话,你会看到满屏的_0x4f2a这类十六进制变量名,以及一个接一个的自执行函数。字符串全部是从数组里按索引取出来的,甚至取索引的索引本身也是加密的。更恶心的是还有控制流平坦化——一段原本顺序执行的代码,会被改写成while(true) + switch的分发结构,逻辑被打散之后完全不成形。
这种状态下的代码,别说分析加密参数了,你连它下一行要干什么都看不出来。所以必须引入AST还原。AST全称Abstract Syntax Tree,抽象语法树,它把JS代码解析成一个结构化树形对象,每个节点对应代码里的一个语法单元。有了这棵树,我们就可以按节点类型做精准操作:把混淆过的CallExpression还原成普通字符串、把SwitchCase分发还原成顺序语句、把无意义的变量名替换成可读名字。
一句话总结我的整体思路:先拿到极验的混淆JS,用AST工具解析成语法树,然后针对不同的混淆手段写对应的还原插件,逐层剥离,最后生成干净代码。整个过程相当于是给JS做一次“拆弹”,每一步都要小心处理,处理错了后面全崩。
1.2 极验混淆JS的典型特征与还原目标
在动手之前,我们先做“敌情侦察”。这些年我分析过的极验代码,混淆手段基本固定在这么几类,识别出来之后就知道该用什么策略去应对。
首先是字符串数组加密。极验JS里会有一个大数组,里面存着一堆编码后的字符串,访问方式类似_0x1a2b3c[0x12]。而且极验不会让你直接拿索引取值,而是通过一个解密函数包装一层,调用形式通常是这样:
var _0x1234 = function(_0xab, _0xcd) { return _0x1357[_0xab - 0x10] + _0xcd; };真正要还原的目标,是把所有_0x1234(0x2a, 0x1f)这样的调用计算出来,替换成最终字符串。
其次是控制流平坦化,这是最大的拦路虎。原始代码a(); b(); c();会被改写成这样:
var _0x25 = 0; while (true) { switch (_0x25) { case 0: a(); _0x25 = 1; break; case 1: b(); _0x25 = 2; break; case 2: c(); _0x25 = -1; break; default: return; } }还原目标就是把这个while+switch的壳子拍扁,恢复成顺序代码。
再次是对象属性名加密。极验有时候会把对象的key打散,运行时通过计算拼出来,比如_0x3f6f[_0x38(0x7)]。这类需要定位到key的计算逻辑,还原成字面量key。
最后是变量名混淆。这个最简单,所有常规变量都会被重命名成_0x开头+一堆十六进制数字的格式。我们需要在变量作用域内把名字改成有语义的名称,或者至少改成不那么读不下去的别名。
明确了这些特征之后,整个项目的目标就很清晰:写一套AST处理流水线,跑完输出一份逻辑清晰、接近原始语义的JS文件。下面进入实操环节。
2. AST核心概念与工具链
2.1 从零理解AST:把代码拆成语法积木
很多人一听到AST就觉得高大上,实际没那么玄乎。用一个大白话类比:代码是人写的,但计算机要理解代码,必须先把字符串转成一棵结构树。树的每个节点就是一个语法单元。比如var a = 1 + 2这句话,解析成AST之后大概是这样的结构:根节点是VariableDeclaration,然后下面有VariableDeclarator,再下面有Identifier(变量名a)、BinaryExpression(加号表达式),加号表达式又包含左右两个NumericLiteral(数字1和2)。
这棵树的每一层都有类型名,比如FunctionDeclaration代表函数声明,CallExpression代表函数调用,IfStatement代表if语句。我们写还原插件,本质就是遍历这棵树,找到特定类型的节点,然后按规则修改它。改完之后,再把树重新生成成代码字符串。整个过程就像你在Word文档里通过样式批量改格式——不是手动逐处改,而是通过匹配规则处理所有同类节点。
常用工具方面,我推荐Babel家族。不是让你用Babel去转译JS,而是借用它的解析器、遍历器和生成器。核心依赖就这几个:@babel/parser(把JS源码解析成AST)、@babel/traverse(遍历并修改AST节点)、@babel/types(节点类型判断和构造工具)、@babel/generator(把AST打印回JS代码,支持压缩、保留注释等配置)。
安装方式很简单:
npm init -y npm install @babel/parser @babel/traverse @babel/types @babel/generator额外推荐@babel/template,它主要用于构造复杂节点,比如你想批量插入一段初始化代码时非常方便。还有@babel/code-frame,报错时能打印出错位置的代码片段,调试插件时很有用。
2.2 操作AST的五个阶段与生命周期
我自己写还原脚本时,基本上固定走五步,每一步都是独立阶段,方便排查问题。
解析阶段:用@babel/parser把代码字符串转AST。这里有个容易踩的坑,极验代码往往是压缩过的,里面可能包含一些比较新的语法或者特别深的嵌套,如果解析报错,可以尝试在parserOptions里开启allowReturnOutsideFunction、allowAwaitOutsideFunction等宽松选项。
遍历阶段:Babel官方推荐并且性能最好的是@babel/traverse默认的深度优先遍历。你要处理什么节点,就写对应的visitor方法。比如对CallExpression节点感兴趣,就写:
traverse(ast, { CallExpression(path) { // 对每个函数调用节点做判断和处理 } });这里还有一个核心概念叫path。path不是节点本身,而是节点在树结构中的带依赖关系的“位置对象”。你不仅要访问当前节点,还要能拿到它的父节点、兄弟节点、作用域信息,path就是干这个的。
第三个阶段是变换,这是重头戏。基于visitor机制,我们对匹配到的节点做增删改查。比如把一段解密函数调用替换成计算结果,直接把CallExpression节点替换成StringLiteral节点就行,Babel的path.replaceWith方法可以搞定。
第四个阶段是清理。很多时候我们做了替换,但会留下一些孤立节点,比如某个函数不再被调用、某个变量不再被引用,它们不会影响运行,但会干扰阅读。我们需要再跑一轮遍历,把UnusedFunction、UnusedVariable之类的“死代码”删掉。
第五个阶段是生成。@babel/generator会把还原后的AST打印成代码文本,可以设置compact: false换行美观,也可以compact: true保留压缩风格。一般我们会选择输出可读性更好的格式。
3. 实操:AST还原极验混淆JS完整流程
3.1 拿到极验的JS文件与初步观察
实际操作时,第一步是拿到混淆JS文件。极验v3滑块一般会加载slide.7.8.5.js这类文件,v4则是gcaptcha4.js之类。你可以通过浏览器开发者工具的网络面板,在触发验证码时过滤JS请求,找到并保存下来。如果是做研究,建议把文件存档到一个固定目录,后续处理脚本统一读入。
拿到文件后别急着写脚本,先用视觉扫一遍了解混淆类型。我习惯先用一个文本编辑器打开,搜索几个特征关键词:有没有大段数组、有没有while(true)或while(!![])、有没有大量_0x开头的变量名。这一步能帮你确定要先处理哪个混淆点。比如我见过一份极验代码,第一眼扫过去就有三四个while(!![])结构,说明控制流平坦化是主要障碍;另一个文件里则没有明显while结构,但函数体几乎被拆成碎片,全是自执行函数调用来组织逻辑。
从工程角度看,我建议把还原流程做成一个可配置的管道脚本,比如deobfuscate.js,里面按顺序调用各个还原插件。这样每次拿到新的混淆变体,只要调整插件顺序和参数即可。
3.2 第一刀:还原字符串数组解密函数
字符串加密是整个还原任务里性价比最高的一步。因为只要处理完这一层,大量函数名、报错信息、逻辑分支就都变成明文了。
常见模式是:文件头部定义了一个大数组,叫_0x2254之类的名字,里面存着编码字符串。然后定义了一个解密函数,比如_0x5a6b,它对数组做下标运算并返回字符串。全代码里会大量调用_0x5a6b(0x2f)这种形式。
我的处理方案分三步。
第一步,定位数组和解密函数。先遍历AST,找到数组节点。极验里这个数组通常是ArrayExpression,元素可能很多,几百个字符串很正常。把数组内容提取到一个映射表里,即索引到编码串的Map。
第二步,找到解密函数体。这个函数的逻辑通常是两个参数:一个是数组下标,另一个是偏移量或某种key,函数内部会把编码串做base64解码、字符移位之类的处理。我们要做的是在Node.js环境里模拟执行这个解密函数,而不是自己去分析它的算法。怎么模拟?解析出函数体之后,构建一个只包含这个函数和数组的临时脚本,用vm模块执行,得到一个可调用的解密函数。
第三步,全代码遍历CallExpression,发现调用者是指向解密函数的引用时,直接计算结果并替换成StringLiteral节点。
这里有个坑要提醒你:极验有时候不只有一个解密函数,或者数组本身又被其他函数包裹了一层。遇到这种情况,最稳妥的方式是先把外层解密函数跑通,再做下一层。代码我写过一个通用插件:
const vm = require('vm'); const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const t = require('@babel/types'); const generator = require('@babel/generator').default; let stringArrayName = ''; let decodeFuncName = ''; // 第一步:扫描找数组和解密函数名,这里需要人工确认特征,通常带移位运算 // ... (略) // 第二步:收集数组内容并执行解密函数 const context = {}; vm.createContext(context); // 把极验里解密函数相关的代码片段放进vm执行 // ... (略) // 第三步:替换调用 traverse(ast, { CallExpression(path) { const callee = path.node.callee; if (t.isIdentifier(callee) && callee.name === decodeFuncName && path.node.arguments.length > 0) { try { const result = vm.runInContext(generator(path.node).code, context); if (typeof result === 'string') { path.replaceWith(t.stringLiteral(result)); } } catch (e) { // 有些参数不是字面量,跳过 } } } });上面代码省略了部分特征扫描逻辑,实际实践中这层还原需要你根据具体文件微调。核心思路就是:先把解密逻辑在黑盒里跑通,然后用“值替换”的方式消除调用。
3.3 第二刀:用状态机思想拍平控制流平坦化
如果说字符串还原是小打小闹,控制流平坦化就是硬骨头。极验在关键函数里普遍使用这种混淆,导致分析时根本无法直接阅读。
我的还原思路基于一个关键洞察:虽然代码表面上进入了while(true)+switch循环,但它本质是一系列按固定顺序执行的状态。只要能把每个case里的执行块按顺序串联起来,循环结构就可以被完整替换成线性代码。
实际操作中,我一般这样做:
- 识别分发变量。在
while上方一定有一个初始值赋给某个变量,比如var _0x25 = 0x3。把这个变量名记下来。 - 遍历
switch的所有case,为每个case建立映射:当前值是多少、执行哪些语句、下一个值是多少(通常case块最后一行是给分发变量重新赋值的语句)。 - 模拟整个循环的执行路径,把访问到的case按顺序记录成一个序列。
- 用一段顺序代码替换整个
while结构。
这里最大的难点在于,有些case块不是顺序执行的,可能会跳来跳去,或者间接改变分发变量的值。所以不能机械地照顺序展开,而是要做执行路径分析。我在项目里用的办法是模拟执行法——不用真正跑它,而是在AST层面做一个轻量的“抽象解释器”:维护一个当前状态值,遇到赋值分发变量的语句就更新状态,然后跳转到对应case,直到状态变成退出条件。
具体代码框架如下:
function flattenControlFlow(path) { const whilePath = path; const loopBody = whilePath.node.body; // 1. 找到switch节点 let switchNode = null; loopBody.body.forEach(stmt => { if (t.isSwitchStatement(stmt)) switchNode = stmt; }); // 2. 收集cases映射 const caseMap = {}; switchNode.cases.forEach(c => { const testVal = evaluateLiteral(c.test); // case值的字面量 caseMap[testVal] = c; }); // 3. 模拟执行 const sequence = []; let dispatchOrder = []; // 从初始值开始追踪 // ... (解析每个case里的最后一个赋值语句,更新状态) // 4. 按顺序拼接语句 const newStatements = []; sequence.forEach(item => { caseMap[item].consequent.forEach(stmt => { // 过滤掉赋值给分发变量的语句 if (!isDispatchAssignment(stmt)) { newStatements.push(stmt); } }); }); // 5. 替换原节点 whilePath.replaceWithMultiple(newStatements); }这段代码只是个骨架,真正执行时需要处理细节,比如case块里可能包含break、return、throw等语句,它们的处理方式不同。我自己在项目里是先把break移除(因为拍平后不再需要break),但保留return和throw。
还有一点需要注意:极验有的代码里控制流平坦化是被包装在多层函数里的,比如外面还有个自执行函数把整个循环包住。这种情况要先处理外层,把外部流程理顺了,再对内层循环做拍平。顺序反了就会一团糟。
3.4 第三刀:变量名还原与死代码清理
处理完字符串和控制流之后,代码已经能看个大概了。但满屏的_0x4f2a、_0x8a2b这种名字依然影响阅读体验,尤其是函数名,全变成_0x4f2a之后根本不知道这个函数是干嘛的。
变量名还原的思路很简单:分析每个标识符的作用域,然后根据它的使用方式重新命名。比如一个函数内部被反复调用的箭头函数,如果里面做了加密参数的构造,你可以手动给它改名成buildParams。实际工程里,全自动还原很困难,因为语义信息已经丢了。我通常的做法是半自动:利用IDE或编辑器把变量名批量替换成可读性更友好的别名,机器帮不了的部分人工介入。比如先跑一遍脚本把_0x开头的变量统一改成v0、v1…… 这样至少比十六进制好读,然后在人读代码的过程中逐步重命名。
死代码清理相对简单。主要清理两类:一类是完全没被引用的函数声明,一类是只在赋值后没被读取的变量。Babel的traverse过程中可以维护一个引用计数,第二轮把所有计数为0的声明删除。注意,清理时不要误删被字符串数组解密函数引用的函数,否则会破坏前面的还原成果。
你是不是觉得这步不必要?我一开始也这么认为,但后来发现极验在混淆时故意插入大量无意义的分支和自执行函数,专门干扰分析。把这些干扰项清掉之后,代码轮廓会清晰很多,后续分析也不容易迷失方向。
3.5 生成还原后的代码
所有插件跑完之后,最后一步是用generator输出。我习惯这样配置:
const output = generator(ast, { comments: false, compact: false, jsescOption: { minimal: true } }, code); fs.writeFileSync('output.js', output.code, 'utf-8');compact: false会保留换行和缩进,方便人阅读。comments: false可以去掉原混淆文件里的干扰注释。jsescOption: { minimal: true }让生成的字符串保持可读性,不会把所有双引号转义成Unicode序列。
输出之后先别急着分析,跑一遍语法检查。我常用node --check output.js验证生成的JS有没有语法错误。如果有报错,基本说明前面某个还原插件误伤到了代码结构,需要回去调整。
如果一切正常,把还原后的代码和原文件对比一下大小。正常情况下会缩小很多,如果反而变大了,翻倍那种,极有可能在某个case块里错误复制了语句,导致代码膨胀。这时回到控制流拍平那一步仔细检查分发变量的更新逻辑。
4. 常见问题与排查技巧实录
4.1 解密函数执行报错或返回值不对
跑字符串还原时,最常见的报错就是ReferenceError: xxx is not defined,因为解密函数依赖的闭包变量没有捕获完整。我在项目里踩过多次这个坑。
解决办法是:不要把解密函数单独提取出来跑,而是把周围相关的自执行函数整体提取到一起。极验的字符串解密函数经常和数组定义在同一个自执行函数里,并且函数内部引用了外层的一些工具函数。我在做这一步时,会先人工判断这个函数的依赖范围,再连同依赖一起放入vm执行。
另外一个坑是解密函数内部可能用了String.fromCharCode、atob这类全局API,需要在vm上下文里预置:
const context = { String: String, atob: (s) => Buffer.from(s, 'base64').toString('binary'), decodeURIComponent: decodeURIComponent };如果返回值不对,多半是编码方式判断错了,仔细看解密函数里的字符处理逻辑,是base64还是自定义移位。实在不行就手动模拟一遍。
4.2 控制流平坦化识别不准导致展开错误
控制流平坦化识别不准,最常见的结果是拍平后的代码缺失语句,或者执行顺序错乱。我建议先在还原流程里加上一个“校验器”:每拍平一个循环,就用node --check检查语法,然后用有限的测试数据跑一遍原函数和还原函数的输出,对比是否一致。别嫌麻烦,这份钱的功夫绝对值得。
极验有些case块里的语句也会修改分发变量,导致模拟执行时会跳过一些分支。我的经验是:不要只扫描case结尾的分发变量赋值,还要扫描case块中的所有赋值表达式,只要左值是分发变量,都需要处理。另外,极验偶尔会在case块里嵌套另一个while循环,而不是直接调用函数。遇到这种嵌套结构,外层拍平前最好先对内层进行处理,保持每个循环体是完整闭环。
4.3 还原后代码行为不一致
这个问题一般是还原插件过度优化导致的。最典型的情况是字符串解密时,你把一个动态调用错误地替换成了静态字符串。如果原始代码中这个调用的参数不是字面量,而是前面某个变量的值,硬替换就会改变语义。
我的对策是:只有在CallExpression的所有参数都是NumericLiteral或StringLiteral时才做替换;如果有参数是Identifier或BinaryExpression,宁可跳过。这样虽然还原得不够彻底,但至少结果是对的。安全第一,完整性第二。
同样,控制流拍平后,如果原代码里有break语句,不要简单删掉——要看它跳出的是while循环还是switch本身。如果原本case里有嵌套循环,break可能是跳出内层循环的,删除必出问题。
4.4 实战排查工具推荐
手动分析混淆JS时,我常用的工具和技巧,顺手分享一下:
一是浏览器开发者工具的美化功能。Chrome的Sources面板里有个{}按钮,可以quick美化压缩代码,虽然不能解密混淆逻辑,但至少换行和缩进有了,比如在调试时可以先看看整体结构。
二是VSCode的“重命名符号”功能。对于还原后的代码,用VSCode打开,选中一个变量名按F2重命名,作用域内所有引用会同步修改,这是做半自动语义还原的大杀器。
三是sourcify这类脚本,可以用AST自动生成部分语义化变量名。不过这类工具处理不了极验这种重度混淆,只能辅助。
四是eslint。给还原后的代码跑一遍no-unused-vars规则,可以快速标记死代码,辅助清理。
5. 写在后面:这套流程的深度价值
从拿到一份极验混淆JS,到通过AST还原成可读代码,整个过程看似只是“写几个脚本”,但每一步背后都需要对JS解析机制、作用域规则、语法树结构有扎实的理解。我个人最大的收获不是最终还原出了什么,而是理解了混淆对抗的攻防逻辑:对方如何隐藏逻辑,我方如何通过结构性分析找到出路。
字符串解密、控制流拍平、变量清理这三大件,基本上能覆盖市面上90%的JS混淆方案。不只是极验,其他滑块验证码、风控产品、甚至一些前端加密算法的混淆代码,都可以用这套AST方法论去还原。工具是死的,思路是活的。你可以把这套处理流程沉淀成自己的工具库,以后遇到类似的代码,先跑一遍标准流程,再针对特殊混淆写额外插件。
再分享一个小经验:还原代码不要追求一步到位。我每次都是跑完一个还原插件就保存一个中间版本,比如output_1_after_string.js、output_2_after_controlflow.js,这样一旦发现后一步出错,可以快速回退,不用从头再来。纸上得来终觉浅,建议你拿一份极验JS亲自跑一遍,处理完之后再回来看这段文字,很多卡住你的点会豁然开朗。下一篇我会继续讲还原之后的逻辑分析阶段,重点拆解极验滑块的加密参数是怎么一步步构造出来的。