ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

极验滑块JS逆向:用AST还原混淆代码的实战指南

极验滑块JS逆向:用AST还原混淆代码的实战指南 搞极验滑块的人基本都绕不过它那一坨被混淆得亲妈都认不出来的JS。一开始我也试过直接浏览器里打断点好家伙变量名全是_0x2e8f3这种十六进制短码函数套函数还带一堆废代码根本没法理清逻辑。后来被逼着去啃AST才算是找到了正门。这篇文章是“极验滑块验证码研究与还原”系列的第一篇先把最基础也最关键的一步讲透怎么用AST抽象语法树把极验那套混淆JS给还原成人能看懂的代码。只有把混淆这一关过了后面分析加密参数、轨迹生成、风控策略才有戏。本文适合有一定前端基础、想深入JS逆向或者做前端安全研究的朋友零基础也能跟着步骤跑通我会把原理和代码都拆开讲。1. 为什么第一步必须是AST还原1.1 极验前端JS到底“混”在哪极验的前端JS不是简单压缩一下就算完事它是经过一整套工具链处理过的常见的手段一个不落。首先是字符串处理所有关键字符串——比如加密算法的key、接口的路径、报错信息——都被抽到一个大数组里下标还是乱的调用的时候通过一个解密函数动态取出来。这招的名字在圈里叫“字符串阵列”刚接触的时候我只想说一句真有你的。其次是变量名和函数名混淆。正常人写代码变量叫timestamp、challenge、captchaId混淆完之后全变成_0x1a2b3c这种无意义短码而且作用域内复用极其严重同一段代码里你根本看不出哪个变量是干嘛用的。再配合控制流平坦化把正常的if/else、for循环打散成一个巨大的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代码支持压缩/美化选项有人可能会问为什么不用esprima、escodegen它们也解析AST但生态没有Babel丰富babel/traverse的路径path机制非常顺手而且Babel的AST结构在社区资料最多遇到问题查起来方便。极验这类大厂混淆代码里充满了ES6语法babel/parser对这些语法的兼容性甩其他库几条街。2. 动手前的准备工作2.1 拿到目标JS文件要还原极验的JS第一步当然是把代码拿到手。打开浏览器开发者工具切到Network面板刷新页面在筛选框里输入gt、geetest之类的关键词能找到好几个JS文件。极验的滑块验证码现在版本比较多常见的有fullpage.7.8.2.js、slide.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_0、var_1这种顺序编号或者按作用域分别编号scope_var_0。这个方案实现快但帮助有限因为var_0和var_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; }还原方法就是从初始state0开始顺着每个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: false、jsescOption、comments: false。我习惯这样配置const output generator(ast, { comments: false, compact: false, jsescOption: { minimal: true // 让字符串尽量用原字符输出 } }).code;这样生成的代码是展开格式的变量名还是有缩减感但不影响阅读。如果要更漂亮可以再接一层prettier格式化我个人一般不用因为Babel生成的缩进已经足够。整个还原流程走到这一步代码已经从“加密文件”变成了“可读工程”。你可以在还原后的文件里搜索gettype、get.php、challenge这些关键词找到极验核心的加解密和请求逻辑位置为下一步分析滑块参数做准备。4. 常见问题与排查技巧实录4.1 还原后代码一运行就报错最常见的情况是字符串替换阶段把动态执行需要的字符串也替换成了字面量但字符串本身是代码片段运行时被eval或者Function使用替换成字面量之后语法反而错了。这种情况报错信息会很怪例如Unexpected token出现在生成代码的某个字符串字面量处。排查思路是先定位到报错行看看这行代码在还原前是什么样子。如果是eval(some code)这种在替换字符串时就要加白名单——字符串调用者是eval或Function时跳过不替换。或者替换完再做一轮语法检查发现有问题的节点单独回滚。4.2 变量名重命名后作用域串了这个问题我在前面提过是全局映射表导致的。当你发现还原后的代码在某个函数内引用了不存在的变量大概率是重命名时把不同作用域的同名变量合并了。解决办法就是作用域隔离——每个函数进来都新建一层映射表查找名字时看当前函数有没有定义没有再去父级找。Babel的path.scope其实已经帮你做好了作用域管理推荐使用path.scope.rename接口做重命名它会自动处理作用域冲突比自己维护映射表靠谱得多。4.3 控制流平坦化还原后逻辑完全对不上这种情况一般出现在状态变量的值不是数字字面量而是通过某个变量间接赋值的。比如state next_statenext_state又是从数组里取出来的。直接比较.value根本取不到需要先解析一轮变量追踪把所有间接引用全部解算成常量。追踪的时候可以用path.evaluate()这个APIbabel/traverse会尝试在静态分析阶段把变量的值计算出来。能用它解决的问题别自己手动去模拟执行。但要注意如果表达式里有函数调用evaluate()通常会返回confident: false这时候就需要你手动处理具体函数了。4.4 极验混淆的“度”是动态变化的不同版本、不同部署环境下极验的混淆程度不一样。有的版本字符串解密函数巨复杂有的版本干脆没有控制流平坦化。所以没有一套插件能通吃所有版本正确思路是先观察、再针对性写插件、最后迭代精修。整个过程用一个字段控制开关哪一步该跑哪一步不跑灵活调节。我一般在项目里维护一个配置文件记录当前目标版本的混淆特征。比如特征当前目标版本字符串阵列存在解密函数为_0xdec风格控制流平坦化存在约 80 个 case标识符混淆_0x前缀动态执行少量eval主要用Function每次还原完一轮更新一次配置免得下次拿到新版本忘记特征。4.5 版本管理不能省还原过程中会反复修改代码很容易把代码越改越乱。我强烈建议每完成一个大步骤就把还原后的文件和对应插件脚本存一个版本。这样万一后面哪一步写错了可以直接回滚到上一个正确版本重写这一步逻辑而不是从头再来。我用Git来管这些中间产物commit信息就写“字符串还原完成”“平坦化处理完成”这种追踪起来非常方便。5. 写在最后两个实操中反复验证的经验折腾极验代码这段时间我最深的体会是AST还原不是一次性跑完就结束的“魔法”。真正实用的流程是“多次迭代、逐层递减”的——字符串还原一遍、平坦化一遍、清理一遍然后又发现有新的混淆模式被暴露出来再写对应的插件处理。这个过程很像剥洋葱你不能指望第一刀就把核心逻辑完整露出来耐心比技巧重要。另外再分享一个小技巧每次运行还原脚本后都会生成一个restore.log日志记录每一类节点被处理的数量。比如“替换字符串调用342处”“重命名变量1289个”“删除死代码56个”。这些数字能帮你判断当前版本的混淆有多重也能在下一次跑的时候对比处理效果有没有提升。有了这个日志定位问题会快很多。最后提一句AST还原与其他代码分析手段配合可以用来理解前端安全机制、分析加密算法、做兼容性修复等合法研究。请确保在合规范围内使用这些技术目标站点或代码的授权确认好再动手这既是对自己负责也是对这个技术方向长期健康发展的负责。
返回列表