拓冰建站拓冰建站
首页 / 资讯中心 / 正文

AST还原混淆JS:极验滑块验证码前端分析实战

搞极验滑块验证码的JS分析第一道坎永远是代码可读性问题。打开压缩混淆后的JS文件满屏_0xabcd这类变量名字符串全被加密函数包着一眼望去就是一堆乱码。很多刚入门的同学会尝试用正则替换、手工格式化硬啃结果往往折腾一晚上也没理出头绪。我的经验是不要跟混淆代码硬碰硬先用AST抽象语法树把它还原成接近可读的状态再做行为分析。这套路做前端反爬、验证码风控研究的人都绕不开。这篇文章只讲AST还原混淆JS这一件事且明确边界还原的目的是分析前端防护逻辑、研究验证码风控机制、提升自身安全建设水位不服务于任何绕过验证码进行批量作弊的行为。基于这个前提下面把我从零开始处理极验滑块混淆JS的全过程按照实操链路拆开讲。1. 拿到极验JS文件那一刻为什么第一步必须交给AST1.1 一次真实的前端分析遭遇先描述一个大家都会遇到的场景。你把极验官网那个fullpage.9.0.5.js不同版本文件名不一样拉到本地用编辑器打开光标落在第一行屏幕上是一行行几乎不换行的代码——这是压缩后的生产包所有变量名都被替换成了_0x3f2a、_0x58be1c这种形式字符串常量被包在形如_0x3f2a(0x1, abc)的解密调用里函数之间的调用关系被强制压成了极深的嵌套表达式。我第一眼看到这种东西的反应是这东西不能靠人眼读必须先机器化处理。而且这里有个关键点很多人容易搞反——处理的第一步不是分析逻辑是先把代码从机器可读变回人类可读。这个步骤做不好后面所有分析都是空中楼阁。1.2 手工还原和正则替换为什么走不通有的同学会问不就是替换变量名吗写个正则匹配_0x[0-9a-f]全局替换不就行了问题在于正则没有上下文概念。变量名、属性名、字符串内容可能同时长得像一个_0x开头的token。你写一个/\b_0x[0-9a-f]\b/g去全局替换运气好能把一部分变量换掉但只要有一个同名token出现在不该出现的位置比如某个加密字符串里正好包含这个片段替换之后代码逻辑直接崩掉。而且极验这类混淆方案里变量名是动态作用域的同一个名字在不同函数里指向完全不同的东西全局统一替换等于把不同人的身份证号全改成同一个必然出错。更致命的是字符串解密。正则能匹配出_0x3f2a(0x1)这个调用却不知道0x1解密出来是什么。解密函数内部还套着其他混淆函数一层套一层手工跟进几乎不可能。这就是我坚持用AST的原因——AST把代码解析成一棵结构化的树每个节点的角色是不是变量、是不是字符串、是不是函数调用是明确的操作可以在正确的上下文里做。1.3 把还原目标拆成四个具体任务拿到一份待还原的混淆JS我习惯先把目标拆成四件事字符串解密把所有_0x3f2a(0x1)这类加密调用还原成明文字符串字面量。变量名可读化把混淆后的无意义标识符改成a、b、_temp或根据用途推断的名称至少要解决同名冲突。控制流平坦化还原把while(1) { switch(state) { case ... } }这种结构拆回原来的顺序逻辑。代码格式化缩进、换行、去掉死代码方便后续阅读。这四件事里字符串解密是性价比最高的做完之后代码可读性直接上一大截控制流平坦化最难但不是每个版本都会用格式化属于收尾动作随手就做。下面我按这个顺序展开先讲清楚对面用了什么手段再讲工具最后讲实操。2. 极验滑块的混淆套路先认清楚对面用了什么武器2.1 四种常用混淆手段的特征识别看混淆代码先判断对面用了哪些手段跟医生看病先辨证是一个道理。我把常见的混淆手段列了个表特征是判断依据混淆手段典型代码特征识别方法难度变量名混淆_0x12ab、_0x3f2a这类hex风格标识符全局搜索标识符命名规律低字符串加密字符串作为参数传给解密函数如_0x3f2a(0x1, key)观察字符串字面量是否大量出现在函数调用实参里中控制流平坦化出现while(1)/while(true)switch(state) 大量case分支搜索while关键字看内部是否紧跟switch高死代码注入大量if(false)块、无实义的自执行函数观察是否有明显不可达分支低极验不同版本的代码用的手段有差异但整体方向一致对象属性名重命名、字符串加密、数组位移混淆把字符串数组打乱后按偏移量取、函数调用关系打散。第一件事就是把这些特征从代码里找出来。2.2 从生产环境样本中定位混淆骨架我的做法是拿到JS文件后先不急着分析先用编辑器自带的全局搜索做三件事。第一步搜while (true)、while (!![])。如果出现大量这类循环基本可以判定用了控制流平坦化。第二步搜形如_0x[0-9a-f](的函数调用注意看调用参数里有没有字符串字面量如果有这就是候选的字符串解密入口。第三步搜一个大数组很多混淆方案会把所有字符串常量集中放到一个数组里通过下标引用数组名一般也是_0x风格数组元素是十六进制转义的字符串长这样\x68\x74\x74\x70。拿一个典型的字符串加密片段举例混淆后的代码长这样var _0x3f2a function(_0x4d5e, _0x8f1a) { var _0x1c3e _0x8f1a[split](); // 内部是一套自定义的编码映射逻辑 return _0x1c3e[_0x4d5e]; }; var _0x58be _0x3f2a(0x1, a1b2c3);原始逻辑是什么不重要重要的是_0x58be最终指向的字符串必须通过_0x3f2a算出来。这种包装方式比直接用明文多了两层心理门槛但对AST来说就是一次函数求值的问题。2.3 混淆与还原的对抗逻辑武器决定战术判断完对面用了哪些武器才能定还原策略。如果只有变量名混淆那写脚本把标识符批量换成可读名字就行如果字符串加密就得先处理解密函数如果既有字符串加密又有控制流平坦化那字符串解密要优先做因为控制流还原需要读懂每个case里的具体操作字符串不解密你连case在干嘛都看不懂。这个顺序很重要。我见过有人先花三天搞控制流平坦化结果发现switch里的字符串全是加密的case里的判断条件根本读不懂等于白做。正确流程永远是先解密字符串再做控制流还原最后做重命名和格式化。3. AST工具链与环境准备解析、遍历、生成三件套3.1 AST到底长什么样一棵描述代码的树AST全称是Abstract Syntax Tree抽象语法树。一句话解释编译器把源代码拆成一棵树每个节点代表一个语法结构。比如下面这行代码var _0x1234 _0x5678(0x1);解析成AST后顶层是Program节点下面挂一个VariableDeclaration节点这个节点里有一个VariableDeclarator它有id左侧的_0x1234和init右侧的表达式init是一个CallExpression它的callee是Identifier(_0x5678)arguments里是StringLiteral(0x1)。把这棵树画出来就是一层套一层的父子关系。你不需要背所有节点类型但至少要认识几个出现频率最高的Program、FunctionDeclaration/FunctionExpression、VariableDeclaration/VariableDeclarator、Identifier、StringLiteral、NumericLiteral、CallExpression、MemberExpression、IfStatement、SwitchStatement、WhileStatement。AST还原脚本的绝大部分工作就是在这几类节点之间做查找、替换、删除。3.2 工具选型为什么是Babel全家桶市面上的JS parser不少有acorn、esprima、espree还有UglifyJS/Terser自带的解析器。但我最终选了Babel全家桶原因有三第一Babel的babel/parser对现代JS语法支持非常全极验这类大型生产JS用了很多ES6语法老牌parser不一定吃得下。第二babel/traverse的遍历API设计得很舒服支持按节点类型精准回调还维护了scope信息这对安全的变量重命名至关重要。第三babel/generator能无损地把修改后的AST转回代码而且支持压缩/美化两种输出模式。我用的工具链固定四件套babel/parser把JS源码解析成ASTbabel/traverse遍历AST、定位节点、修改节点babel/generator把AST转回JS源码babel/types用于判断节点类型、构造新节点3.3 最小可运行环境搭建先建一个项目目录初始化npm环境然后安装依赖mkdir geetest-ast cd geetest-ast npm init -y npm install babel/parser babel/traverse babel/generator babel/types --save安装完写一个最基础的解析与生成脚本验证环境没问题const parser require(babel/parser); const traverse require(babel/traverse).default; const generate require(babel/generator).default; const t require(babel/types); const fs require(fs); const code fs.readFileSync(fullpage.js, utf-8); const ast parser.parse(code, { sourceType: script, allowReturnOutsideFunction: true }); traverse(ast, { CallExpression(path) { // 这里后续写还原逻辑 } }); const output generate(ast, { compact: false }).code; fs.writeFileSync(fullpage.deobfuscated.js, output, utf-8);这段代码是这个系列所有还原脚本的地基。后续的所有操作本质上都是往这个骨架里填回调函数。记住两个容易踩的坑第一sourceType要根据JS文件实际情况选如果文件里包含import/export就要用module否则用script第二极验某些版本会用到浏览器环境特有的语法或特性babel/parser解析不了时要加plugins参数最常见的如[decorators-legacy, classProperties]具体加哪些看报错信息来定。4. 字符串解密还原实操从加密调用到明文直读4.1 定位解密函数与解密入口字符串解密的第一步是找到解密函数本身。极验的混淆脚本里字符串解密函数的名字也是混淆过的但特征很明显它是一个被高频调用的函数大概率定义在文件头部接收一个形如0x1的下标字符串和一个key字符串返回一个明文。定位方法很简单在上一章的CallExpression回调里加一行打印把callee名字和参数个数统计一下看看哪个函数被调用的次数最多、参数是不是字符串字面量traverse(ast, { CallExpression(path) { const callee path.node.callee; if (t.isIdentifier(callee)) { const name callee.name; const argCount path.node.arguments.length; stat[name] (stat[name] || 0) 1; } } });跑完之后看统计结果那个调用次数异常高、参数形式固定的函数九成是解密入口。拿到解密函数名之后下一步是把解密函数体抠出来放到一个可以执行的上下文里。这里有个常见误区直接把整个混淆JS用eval执行然后在内存里调用解密函数。这样做风险很大——极验代码里除了解密函数还有一大堆检测环境、绑定事件、发起网络请求的逻辑一执行就跑飞了甚至可能触发风控。正确做法是单独把解密函数以及它依赖的工具函数抠出来组装成一个独立的Node模块。极验的解密函数往往依赖一个全局字符串数组这个数组也要一并抠出来。4.2 核心还原脚本编写思路解密函数在Node里能跑通之后回到AST脚本遍历所有CallExpression凡是callee是解密函数名且参数是字符串字面量的就把参数代入解密函数执行得到结果后替换成StringLiteral节点function decrypt(value, key) { // 这里是从扣出来的解密模块里导出的函数 return decryptImpl(value, key); } traverse(ast, { CallExpression(path) { const { node } path; const callee node.callee; const args node.arguments; if (t.isIdentifier(callee) callee.name _0x3f2a) { if (args.length 2 t.isStringLiteral(args[0]) t.isStringLiteral(args[1])) { const result decrypt(args[0].value, args[1].value); if (result ! undefined) { path.replaceWith(t.stringLiteral(result)); } } } } });这里面有个关键细节遍历过程中用path.replaceWith替换节点时Babel会把新节点插入到当前遍历路径里。继续遍历时如果不对新节点做标记有可能被后续的匹配规则重复处理——但实际上被替换成StringLiteral之后就不再是CallExpression了一般不会二次触发。真正要注意的是解密函数调用参数不一定全是字符串字面量有时候参数本身是变量比如var _0x1a 0x1; var _0x58be _0x3f2a(_0x1a, key);这种情况下没法直接求值处理方式是把变量_0x1a的初始化值查出来手动先做一层常量传播再做解密。我的做法是先写一个常量收集阶段把VariableDeclarator里的StringLiteral初始化值收集成一个映射表遇到实参是Identifier时查表解析。4.3 处理解密函数自身的依赖问题字符串解密阶段我踩过最大的坑是解密函数内部依赖另一个混淆函数而那个函数又被其他逻辑引用强行抠出来之后运行环境不完整。举个例子解密函数里可能用到了_0x4d5e这个函数做字符映射但这个_0x4d5e函数体里又引用了_0x8f1a数组数组又是通过另一个自执行函数动态生成的。层层依赖下来等于是把代码执行环境搬了一遍。我的解决思路分两步走。第一步先按依赖链把相关的函数和数组一次性抠出来组成一个独立模块。第二步如果某个依赖是动态计算出来的比如数组元素通过位移算法生成那就在独立模块里把那段自执行代码也保留下来直接在Node里跑。测试通过之后把解密函数的导出做成纯函数供AST脚本调用。这里有个实战技巧如果你判断某段依赖实在太复杂、抠不干净可以用一个更粗暴但有效的方式——在混淆JS的开头注入一段探针代码让它在运行时把解密函数每次调用的入参和返回值dump到全局数组里再把整个JS放在一个可控的浏览器安全沙箱里跑一遍收集映射表。这种方式不用抠函数适合依赖极深的情况但要求运行环境可控不适合有严格防调试的场景。5. 变量名还原与控制流平坦化真正拉开差距的两块硬骨头5.1 变量名批量还原的scope安全策略字符串解密做完代码可读性已经提升了一个档次但变量名还是_0x风格读起来依然费劲。这时候做变量重命名。这里必须强调一个原则不要简单地path.replaceWith(t.identifier(newName))一把梭。因为JS的作用域规则决定了同一段代码里可能有多个互不相关的变量都叫_0x1234也可能有一个变量在多个作用域里被引用。如果不知道这个变量的作用域边界全局替换会制造出大量名字冲突代码直接无法运行。Babel的babel/traverse帮我们解决了这个问题。在回调里可以通过path.scope拿到当前标识符的绑定信息调用scope.generateUidIdentifier(name)生成一个安全的新标识符traverse(ast, { Identifier(path) { if (/^_0x[0-9a-f]$/.test(path.node.name)) { const binding path.scope.getBinding(path.node.name); if (binding) { binding.scope.rename(path.node.name, v_ counter); } } } });需要注意这里用binding.scope.rename而不是path.replaceWith。原因在于rename会同时修改该作用域内所有对这个变量的引用而replaceWith只改当前这一个节点。逐个替换必乱作用域级替换才是正解。如果只是想临时把代码变得可读不需要语义化命名统一生成v_0、v_1这种序号型名字就够了。如果想做得更精细可以结合使用场景推断作为函数名的改成fn_xxx作为字符串下标使用的改成idx_xxx在解密调用里作为key的保持原参数名即可。做到这个程度代码基本已经能读了。5.2 控制流平坦化的识别方法控制流平坦化是极验混淆里对可读性杀伤力最大的一招。原本这么写的逻辑function demo() { console.log(start); var x 1 2; console.log(x); }经过平坦化之后逻辑被打散到一个大循环和switch-case里state变量控制执行顺序case块的排列顺序被打乱看起来就像function demo() { var state 0; while (true) { switch (state) { case 0: console.log(start); state 6; break; case 6: var x 1 2; state 2; break; case 2: console.log(x); return; } } }这不是靠人眼能快速恢复原状的东西。识别它的方法前面已经提过——搜while(true)和switch的组合判断标准是switch的discriminant判断变量是一个局部变量并且每个case末尾都对这个变量做重新赋值case的排列顺序和实际执行顺序明显不一致。5.3 控制流平坦化的还原思路与踩坑记录控制流平坦化的核心还原思路是模拟执行从state的初始值出发走到哪个case就记录哪个case的操作遇到state被赋新值就跳转到对应case直到遇到return/break为止。把所有经过的case块按顺序拼接起来就是还原后的顺序逻辑。实际操作中我一般不写完整的符号执行引擎而是用一个更朴素的方案把整个while循环体里的SwitchStatement提取出来遍历所有SwitchCase把它们之间的跳转关系解析成一个有向图。每个case的test值是一个字符串或数字case体末尾的赋值语句指定下一个state这样跳转表就出来了。然后从初始state开始深度优先遍历这张图把顺序记录下来。这里有一个非常容易踩的坑case块的顺序不是真实的执行顺序所以不能直接按代码里的顺序拼接。必须严格按照跳转表走否则还原出来的逻辑是乱的。完整还原方案写起来篇幅很大这里先给个核心框架const stateName state; const whilePath findWhileLoop(ast); const switchCases []; whilePath.traverse({ SwitchCase(path) { switchCases.push(path.node); } }); // 建立 test值 - case索引 的映射 const caseMap {}; switchCases.forEach((node, idx) { if (node.test) { caseMap[node.test.value] idx; } }); // 从第一个case开始解析每个case末尾的state赋值得到跳转序列 let current caseMap[initialState]; const sequence []; while (current ! undefined) { sequence.push(current); const nextState extractNextState(switchCases[current]); current caseMap[nextState]; } // 按 sequence 拼接 caseBody生成还原后的顺序代码extractNextState这一步要处理的情况比较多state赋值可能是state 1这种直接赋值也可能是state state 1这种增量赋值还可能state通过一个数组索引间接计算。遇到后两种就得把赋值表达式也做静态求值。好在极验的大部分版本用的是直接赋值直接解析AssignmentExpression的右侧就能拿到值。我踩过的一个实际问题是case块里有var声明时把这些case按顺序拼接后变量声明会重复出现导致运行时变量重复声明报错。处理方式是在拼接时先去重变量声明把声明提取到还原后块的最顶部。另一个问题是case块里的continue它对应的是while循环的continue在还原后往往需要去掉或转换成正常的流程控制。这两个问题都排查了很久才定位到根因建议大家在实现时提前考虑。6. 还原后的代码验证与调试不是生成完就结束了6.1 静态比对与语法校验代码生成之后第一件事不是去看结果好不好读而是确认还原后的代码语法正确、可运行。我的验证流程分三层。第一层用node --check或eslint做语法检查。生成的文件有语法错误直接在这一步暴露。第二层对比还原前后关键结构数量比如FunctionDeclaration的个数、顶层VariableDeclaration的个数数量差异过大说明还原过程中误删了内容。第三层抽样对比字符串值。解密前和解密后出现过的明文字符串应该完全一致解密数量应该等于原始加密调用数量减去个别无法处理的样本。6.2 运行行为对比验证语法检查通过只代表代码没有语法错误不代表逻辑正确。更严格的做法是对原混淆代码和还原后代码做行为比对。具体思路是找到极验JS里几个关键函数把它们从各自的上下文里抽出来用mock环境构造相同的输入对比两次执行的结果。比较典型的是找滑块轨迹相关的加密函数传入一组预设的轨迹数据查看输出是否一致。实际操作中很多函数依赖浏览器环境window、document、navigator直接Node运行会报错。我的做法是做一个轻量级的浏览环境mock把这些全局对象用global.window {}的形式塞进去mock掉主要的API。如果某个函数纯逻辑、不依赖DOM只需要构造全局环境就行如果依赖DOM就需要更重的mock比如用jsdom把document建起来。注意一点行为对比尽量在还原早期就做不要等到全部还原完成后再验证。因为问题发现得越晚定位越难。我习惯每完成一个还原阶段就用关键函数做一次行为对比确保这个阶段没有引入逻辑偏差。6.3 排查链路一次还原后代码行为异常的完整复盘这里分享一次真实的排查过程完整还原一下我的思路给大家一个参考模板。现象字符串解密完成后跑行为对比某个函数输出的字符串出现了undefined片段比如正常结果应该是xxx|yyy还原后变成了xxx|undefined。这说明某个变量在解密时解码到了错误的值。排查过程从报错信息倒推。先定位到输出的undefined来自哪一行代码发现它拼接了一个变量_0x58be而这个变量在还原前的代码里是_0x3f2a(0x1, key)的返回值。手动在Node里调用解密函数传0x1和key发现确实返回了undefined。问题出在哪检查解密函数的参数发现极验的解密函数并不是简单地用下标取字符串而是先把下标字符串转成数字再对字符串数组做某种位移计算。如果我传给解密函数的数组是静态的而实际运行时数组被某个初始化函数重新洗牌过那静态解析的下标自然对不上。定位到根因后修复方案是不再手动抠数组而是在独立模块里完整保留数组的初始化过程让解密函数在被调用前先执行初始化逻辑再导出。修复后再次执行行为对比输出恢复正常。这条链路的教训是字符串解密遇到解密结果不符合预期时优先怀疑解密上下文不完整而不是怀疑解密函数本身。先把解密函数在独立环境里跑通再进AST脚本能省掉很多排查时间。7. 写在最后AST还原在整个研究链条中的位置与边界AST还原只是极验滑块验证码研究的第一站。把JS还原成人可读的代码之后才有资格谈下一步——分析滑块轨迹的加密参数怎么生成、校验逻辑在哪个环节触发、风控系统采集了哪些环境特征。这几个方向每一块都能单独开一个系列讲。从技术栈本身来说AST这套东西的价值不局限于极验。任何混淆JS不管是某个网站的前端安全SDK还是新兴的WebAssembly前的JS端防护层第一步都是先把它还原到可读状态。掌握Babel全家桶的解析、遍历、生成三板斧等于拿到了前端代码分析的基础工具。后续向上可以研究运行时hook、DOM事件追踪向下可以深入解析引擎的字节码层面。但技术的边界要拎清楚。研究验证码和滑块风控的正当场景包括安全测试、防护方案评估、风控策略调优、教学研究。如果你的项目目的是批量绕过验证码、自动化薅羊毛、爬取受限数据那这篇文章的方向就不对。我在实际工作中见过不少人对验证码研究感兴趣但一上来就问能不能直接出绕过方案这种心态对个人成长没好处对安全生态更是有害。如果只是单纯想练习AST还原我建议你从自己的前端项目开始拿webpack打包后的代码练手再逐步升级到带字符串加密的商业混淆样本。先跑通字符串解密再啃控制流平坦化每个阶段都要拿行为对比验证结果是否正确。这个过程本身比任何教程都更能提升你对JavaScript语言模型的理解深度。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门