从AST到反混淆:Akamai混淆JS的可读化还原全流程实战

发布时间:2026/9/16 10:48:08
从AST到反混淆:Akamai混淆JS的可读化还原全流程实战 做前端安全和爬虫逆向的朋友迟早会遇到 Akamai 生成的混淆 JS。我最初看到那堆被 base64 字符串、十六进制数组和一堆莫名其妙函数套娃包裹的代码时第一反应同样是头大。用正则去抠字符串抠半天发现解密函数还依赖各种运行时状态直接复制进 Node 执行又缺环境。后来老老实实转向 AST抽象语法树方案才把整个分析路径走通。这篇文章就用一次实际分析经历为主线讲讲如何用 AST 把 Akamai 混淆代码一步步还原成可读逻辑覆盖从解析、遍历、节点替换到代码生成的全流程。适合有一定 JS 基础、正在跟混淆代码较劲的开发者参考。1. 内容整体设计与思路拆解1.1 为什么 AST 能成为反混淆的利器混淆代码的本质是让人类难以阅读但机器仍然能执行。既然浏览器要执行代码里的信息就不可能完全丢失只是被打散、编码、藏进了各种结构里。早期很多人用正则去匹配字符串或者函数调用但 Akamai 这类商业混淆器生成的代码高度动态字符串可能被拆成多段拼接函数名和变量名全是_0x3f2a这种无意义标识符还用大量自执行函数改变作用域正则很难覆盖所有变化改一处漏十处。AST 之所以好用是因为它把代码转换成结构化的树形数据每个语法节点都有明确的类型和父子关系。我们可以在不关心格式、不关心空白的前提下程序化地遍历整棵树的每一个节点找到“字符串字面量”“函数调用”“条件语句”这些抽象概念再按规则改写节点最后重新生成代码。整个过程由程序控制比肉眼和正则可靠得多。举个例子混淆后的代码里经常出现这种结构var _0xabc function() { var _0xdef [hello, world]; return function(_0xxyz) { return _0xdef[_0xxyz]; }; }();这段代码本质上就是一个字符串数组加一个索引函数。在 AST 视角下我们可以找到数组节点ArrayExpression找到包裹它的函数FunctionExpression再找到返回函数的内部逻辑通过静态分析确定索引规则然后把所有_0xabc(0)替换成字符串字面量hello。这种转换逻辑用 AST 实现非常自然用正则却异常痛苦。1.2 Akamai 混淆 JS 的常见特征Akamai 的 JavaScript 混淆并不是单一技术而是一套组合拳。我接触过的样本里常见特征有这些大量字符串数组把字符串放进十六进制或普通数组通过下标索引引用。多级解密函数数组被多个函数层层包装真正的解密函数藏得很深参数也经过编码。自执行函数IIFE利用(function(){...})()改变变量作用域让外部无法直接访问内部函数。控制流平坦化把原本顺序执行的代码改造成while switch加状态变量的形式破坏阅读顺序。死代码注入在关键逻辑周围插入大量永远不会执行的分支干扰分析。标识符重命名变量、函数名全部改成无意义短名称甚至用 Unicode 转义。这些技术单独拿出来都不难处理难的是它们组合在一起。比如先做控制流平坦化再做字符串编码最后统一重命名整个还原流程就得按逆向顺序逐步解开。1.3 反混淆的整体流程四阶段我一般把完整的反混淆流程拆成四个阶段每个阶段都有明确的目标解析成 AST把源代码交给解析器输出一棵结构完整的抽象语法树。静态分析定位混淆模式在 AST 里搜索字符串数组、解密函数、平坦化控制流等特征节点理解它们之间的关系。遍历 AST 执行还原转换根据分析结果编写 traverse 逻辑对符合模式的节点进行替换、删除、重排反复迭代。生成代码并验证结果用生成器把 AST 还原成 JavaScript 源码再用格式化工具整理最后通过执行结果或简单断言验证还原是否正确。这四个阶段不是一次完成的。通常我会先把字符串解密跑通再处理控制流平坦化再做变量名重命名和死代码移除每一步之后都生成一份临时代码肉眼扫一眼有没有明显异常。2. 核心细节解析与实操要点2.1 从源码到 AST选对解析器主流 JS 解析器有三个acorn、espree和babel/parser。我个人优先推荐babel/parser因为 Babel 生态太成熟了后续的babel/traverse和babel/generator能无缝衔接而且它对 JS 新语法支持很好解析失败率低。如果你的样本代码用了特别新的语法甚至是 JSXBabel 也能通过插件处理。解析代码的入口非常简单const parser require(babel/parser); const ast parser.parse(sourceCode, { sourceType: script, plugins: [optionalChaining, nullishCoalescingOperator] });如果你的样本里有import/export就把sourceType改成module。如果解析报错通常是因为某个语法插件没开把报错信息里的语法名加进plugins数组就行。拿到 AST 之后我强烈建议先在 https://astexplorer.net 上把同段代码解析看一下树形结构。babel/parser生成的 AST 节点类型大多是File、Program、VariableDeclaration、FunctionDeclaration、CallExpression这类名字每个节点还带着start、end、loc等位置信息这些信息在排查问题时非常有用。2.2 字符串解密的本质从调用到字面量Akamai 混淆代码里最典型的一类模式就是定义字符串数组和解密函数然后在代码里通过调用拿到真实字符串。例如var _0xarr [0x6c, 0x6f, 0x67]; function _0xdec(_0xa) { return String.fromCharCode(parseInt(_0xarr[_0xa], 16)); } var msg _0xdec(0) _0xdec(1) _0xdec(2);这段代码还原后的结果就是msg log。要做这种还原我的思路是先找到数组和解密函数然后在受限环境里执行它、得到返回值最后把_0xdec(...)的调用表达式替换成真实字符串。但直接执行整个解密函数不一定安全。AK 的混淆代码启动时可能会有很多初始化逻辑执行整个文件可能触发环境检测或者死循环。所以要采用“提取沙箱执行”的方式只提取出数组和解密函数这几段独立的声明组合成一个临时脚本丢进 Node 的vm模块执行。这里有个关键点vm不是万能的有些解密函数会引用window、document等浏览器对象执行时就会报错。这种情况下可以根据报错信息给沙箱的context添加对应的 mock 对象。例如const vm require(vm); const sandbox { window: {}, document: {}, String: String, parseInt: parseInt, fromCharCode: String.fromCharCode }; vm.createContext(sandbox); vm.runInContext(extractedCode, sandbox);如果解密函数依赖的对象太多mock 成本会很大。另一个思路是不执行而是做纯静态分析通过 AST 找到函数内部的返回值表达式递归展开成一个等价的 JS 表达式。但这种方案实现复杂遇到循环和条件分支就很难处理所以针对单一样本我通常优先尝试沙箱执行不行再手动分析。2.3 控制流平坦化还原的核心思路控制流平坦化是 Akamai 里比较头疼的一环。它会把原本顺序执行的代码改造成类似这样的结构var _0xstate 0; while (true) { switch (_0xstate) { case 0: console.log(a); _0xstate 1; break; case 1: console.log(b); _0xstate 2; break; case 2: return; } }这段代码虽然语义上等价于顺序执行三条语句但阅读起来非常吃力。还原这个结构的关键在于分析状态变量在 switch 各个 case 之间的流转关系。只要状态变量的赋值是简单的常量赋值例如_0xstate 1我们可以从初始状态出发遍历 case 分支按顺序收集每个 case 里的真实语句然后把完整的WhileStatement替换成顺序的语句列表。需要注意真实混淆里的 switch 可能不是从 case 0 开始的也有可能存在多个入口甚至某些 case 会循环回来。如果是简单的线性流转上面的思路够用如果存在循环就需要先判断循环边界再选择保留 while 结构或者展开成更复杂的控制流。对于 Akamai 的多数样本线性流转的情况占大多数所以这个方法很实用。2.4 代码生成与格式化AST 改完之后最后一步就是把 AST 转回 JavaScript 代码。我用的是babel/generatorconst generate require(babel/generator).default; const output generate(ast, { compact: false, comments: true }).code;生成出来的代码虽然可读但格式可能还不是很好看。进一步可以用prettier或者js-beautify格式化。不过我实际使用中更喜欢把生成结果直接丢回astexplorer里对着看一边看一边确定下一步要找什么混淆特征。3. 实操过程与核心环节实现3.1 环境准备与依赖安装先创建一个工作目录初始化项目mkdir akamai-deobfuscate cd akamai-deobfuscate npm init -y安装四个核心依赖npm install babel/parser babel/traverse babel/generator babel/types这四个包分别是解析、遍历、生成、类型工具。我习惯把样本代码保存成input.js还原结果输出到output.js。下面所有示例都基于这个环境。3.2 编写基础解析脚本先写一个基础的读取与解析脚本确认 AST 能正常生成const fs require(fs); const parser require(babel/parser); const generate require(babel/generator).default; const source fs.readFileSync(input.js, utf-8); const ast parser.parse(source, { sourceType: script }); // 先输出一段看看结构 console.log(generate(ast).code.slice(0, 200));如果这段脚本跑通说明解析没有问题。接下来就可以开始编写遍历替换逻辑了。3.3 实战处理字符串解密假设我在样本里找到一个典型的字符串解密函数经分析它长这样var _0xlist [a, b, c]; function _0xget(_0xi) { return _0xlist[_0xi]; } var name _0xget(0) _0xget(2);我们的目标是把调用_0xget(0)替换成字面量a。为了做到这一点先用vm执行包含_0xlist与_0xget定义的提取代码得到可调用的函数引用然后在 AST 遍历中碰到_0xget(...)调用时直接计算结果并替换。但这里有个问题外部脚本里的var _0xget定义是在 AST 里的怎么才能让vm访问到一种简单的方法是从源码中用正则或 AST 提取出需要的声明片段拼接后交给vm。这个提取逻辑不算复杂用 AST 找VariableDeclaration和FunctionDeclaration的起始位置即可。我常用的沙箱执行代码是这样的const vm require(vm); const sandbox { String: String, parseInt: parseInt, JSON: JSON, console: console }; vm.createContext(sandbox); vm.runInContext( var _0xlist [a, b, c]; function _0xget(_0xi) { return _0xlist[_0xi]; } , sandbox);然后遍历 ASTconst traverse require(babel/traverse).default; const t require(babel/types); traverse(ast, { CallExpression(path) { const callee path.node.callee; if (t.isIdentifier(callee, { name: _0xget })) { const args path.node.arguments; if (args.length 1 t.isNumericLiteral(args[0])) { const result sandbox._0xget(args[0].value); path.replaceWith(t.stringLiteral(result)); } } } });这段代码的核心是判断调用表达式是否为_0xget(数字)如果是就在沙箱里执行同一个函数得到结果再把调用节点替换成字符串字面量。替换后 AST 里的_0xget(0)就变成了a。当然真实场景下解密函数可能不止一个函数名也不是这么友好这时需要先写一个“模式匹配”阶段把所有解密函数找出来建立一个“函数名 - 可执行引用”的映射再在遍历阶段统一处理。3.4 还原控制流平坦化的关键实现处理字符串解密后我通常会接着处理控制流平坦化。以一个简单的 while switch 样本为例let state 0; while (true) { switch (state) { case 0: foo(); state 1; break; case 1: bar(); state 2; break; case 2: baz(); return; } }在 AST 层面我们要找的是WhileStatement并且判断它的 body 是BlockStatement其中第一层是一个SwitchStatementswitch 的分辨表达式中有一个变量这里是state。然后遍历 switch 的cases收集每个 case 下的 statements但要去掉修正状态变量的赋值state 1和break。实现思路如下traverse(ast, { WhileStatement(path) { const node path.node; if (!t.isBooleanLiteral(node.test, { value: true })) return; const body node.body; if (!t.isBlockStatement(body)) return; const switchStatement body.body.find(item t.isSwitchStatement(item)); if (!switchStatement) return; const resultNodes []; const stateVarName switchStatement.discriminant.name; for (const switchCase of switchStatement.cases) { for (const stmt of switchCase.consequent) { if (t.isBreakStatement(stmt)) continue; if (t.isExpressionStatement(stmt) t.isAssignmentExpression(stmt.expression) t.isIdentifier(stmt.expression.left, { name: stateVarName })) { continue; } resultNodes.push(stmt); } } path.replaceWithMultiple(resultNodes); } });这个还原逻辑非常简化只处理了线性流转而且默认是按 case 顺序收集。真实混淆里 case 顺序可能与执行顺序不一致需要先通过 state 赋值建立映射再按从初始 state 开始的实际顺序收集。但上面这段代码非常适合用来理解 AST 操作的核心思想找到目标结构拆解出关键节点重组后替换。3.5 完整脚本流程与验证把字符串还原和控制流还原整合到一个脚本里就形成了一个最小可用的反混淆工具。脚本大致结构是读取input.js。用parser.parse生成 AST。用沙箱执行提取出的解密函数。遍历 AST替换所有解密函数调用。遍历 AST识别并还原 while switch 控制流。用generate生成代码写入output.js。输出后我会用node output.js看看运行结果是否预期。但有的样本并不适合直接运行因为它可能有环境检测那么我就在有没有输出日志的变化或者通过对比关键变量值来验证。比如样本本来会输出log还原后代码变成直接赋值var name log虽然代码形态不同但运行时行为应该完全一致。把原始代码和还原后代码分别放进浏览器环境和 Node 环境跑一遍对比 console 输出就能有效验证还原正确性。4. 常见问题与排查技巧实录4.1 遍历 AST 不生效最常见的问题是babel/traverse的导入方式。在 CommonJS 环境下直接require(babel/traverse)返回的是一个大对象必须取.default才是函数const traverse require(babel/traverse).default;如果没取.default调用traverse(ast, {})会直接报错或者没有任何反应。另外Babel 的节点类型在不同版本里可能叫StringLiteral或Literal建议使用babel/types里的isStringLiteral方法而不是手写node.type StringLiteral这样能避免新版本变动带来的兼容问题。4.2 替换节点后生成的代码不完整有时候用path.replaceWith替换后生成结果里某些节点被插到奇怪的位置甚至报错。这通常是因为替换时没有保留parent信息。Babel 的replaceWith会自动处理父子关系但如果你手动修改 AST 数组比如body.push(...)就需要确保新节点有自己的loc和start。建议一律使用path提供的 API而不是直接操作节点数组。4.3 解密函数执行结果依赖运行环境Akamai 的混淆代码经常检测浏览器环境比如检查window.chrome、navigator.webdriver等。如果你在 Node 沙箱里执行解密函数这些环境变量不存在解密函数可能走不到正常分支。我的处理办法是先分析解密函数是否引用了window、document、navigator等对象如果引用了就在沙箱里预置好的 mock 值。mock 完还不行就退回到纯静态分析手动模拟函数内部逻辑。4.4 控制流还原后执行顺序错乱这是最隐蔽的问题。简单的线性状态机按照 case 列表顺序收集语句但如果原始代码中 case 分支的排列顺序并不是真正的执行顺序还原就会出错。正确做法是根据状态变量的赋值关系构建一个有向图每个 state 是一个节点case里的赋值语句是边然后从初始 state 开始做深度优先遍历按遍历顺序输出语句。遇到循环时还要额外处理否则会无限循环。4.5 性能很慢脚本跑很久AST 节点数可能多达几十万遍历所有路径会非常慢。优化方法是在遍历前先用简单特征过滤比如只在函数名匹配_0x开头的节点里做复杂判断。在traverse的 enter 回调里如果判断当前子路径不包含目标模式直接调用path.skip()。把替换和收集分两遍做避免在一遍遍历里反复修改 AST 导致后续节点失效。我曾经处理过一个 2MB 的 AK 混淆样本未优化前遍历耗时接近十分钟优化后大约四十秒效果非常明显。4.6 还原后的代码语法错误生成器默认输出的代码一般不会语法错误但如果你删除了某些声明却还有引用残留就会出现ReferenceError。这种情况我通常先用 ESLint 或node --check快速检查语法再用 grep 搜索残留的混淆变量名定位漏删的位置。4.7 从防御视角重新看待混淆做反混淆不只是为了绕过。理解了 Akamai 的混淆结构后我反而更清楚它的防护设计字符串加密让敏感逻辑难以直接提取控制流平坦化增加人工审计成本环境检测让脚本不能轻易在非浏览器环境运行。这些设计思路本身值得学习我们在设计自己的前端防护方案时完全可以借鉴其中的合理部分。同时这类分析必须限制在授权测试和安全研究范围内千万不要把它用在未经允许的爬取或攻击场景中。5. 复盘一条可复用的分析路径经过几次完整的 Akamai 混淆 JS 还原我总结出一条相对稳定的分析路径现在把它分享出来。第一步先跑起来。看样本是否有入口函数能否在浏览器里打开并触发。如果能在浏览器环境运行用开发者工具断点看解密函数输出会节省很多分析时间。第二步找特征。打开 AST 可视化工具搜索ArrayExpression、SwitchStatement、FunctionExpression大量出现的位置快速定位混淆核心区域。第三步先还原字符串再还原控制流。顺序不要反。字符串还原后控制流的 case 分支里会变成可读文本分析状态流转会容易很多。第四步每做一步就生成一次代码用 diff 工具对比前后的结构变化确认没有破坏原有逻辑。第五步做一个小型自动化测试集。用几段已知逻辑的代码先做混淆再对我写的反混淆脚本做验证能有效防止还原逻辑在真实样本上翻车。这套路径虽然不能覆盖所有 Akamai 变种但能解决大部分样本。后续遇到新的混淆手法只需要在这个框架上新增对应的 AST 转换模块工作量和风险都比每次从头分析低得多。最后分享一点个人体会AST 操作没什么黑魔法它就是把代码拆成积木再按你想要的规则重新拼装。我在刚开始接触 AST 时连path.node和path.parent都经常搞混后来发现只要多去astexplorer.net上手点一点把常见节点类型和遍历方法摸熟后面再复杂的混淆也只是时间问题。希望这篇文章能帮你少踩一些我踩过的坑。