从AST到反混淆:Akamai混淆JS的可读化还原全流程实战
2026/9/16 10:47:59 网站建设 项目流程

做前端安全和爬虫逆向的朋友,迟早会遇到 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 反混淆的整体流程四阶段

我一般把完整的反混淆流程拆成四个阶段,每个阶段都有明确的目标:

  1. 解析成 AST:把源代码交给解析器,输出一棵结构完整的抽象语法树。
  2. 静态分析定位混淆模式:在 AST 里搜索字符串数组、解密函数、平坦化控制流等特征节点,理解它们之间的关系。
  3. 遍历 AST 执行还原转换:根据分析结果,编写 traverse 逻辑,对符合模式的节点进行替换、删除、重排,反复迭代。
  4. 生成代码并验证结果:用生成器把 AST 还原成 JavaScript 源码,再用格式化工具整理,最后通过执行结果或简单断言验证还原是否正确。

这四个阶段不是一次完成的。通常我会先把字符串解密跑通,再处理控制流平坦化,再做变量名重命名和死代码移除,每一步之后都生成一份临时代码,肉眼扫一眼有没有明显异常。

2. 核心细节解析与实操要点

2.1 从源码到 AST:选对解析器

主流 JS 解析器有三个:acornespree@babel/parser。我个人优先推荐@babel/parser,因为 Babel 生态太成熟了,后续的@babel/traverse@babel/generator能无缝衔接,而且它对 JS 新语法支持很好,解析失败率低。如果你的样本代码用了特别新的语法,甚至是 JSX,Babel 也能通过插件处理。

解析代码的入口非常简单:

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 节点类型大多是FileProgramVariableDeclarationFunctionDeclarationCallExpression这类名字,每个节点还带着startendloc等位置信息,这些信息在排查问题时非常有用。

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不是万能的,有些解密函数会引用windowdocument等浏览器对象,执行时就会报错。这种情况下,可以根据报错信息,给沙箱的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/generator

const 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 找VariableDeclarationFunctionDeclaration的起始位置即可。

我常用的沙箱执行代码是这样的:

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);

然后遍历 AST:

const 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,其中第一层是一个SwitchStatement,switch 的分辨表达式中有一个变量(这里是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 完整脚本流程与验证

把字符串还原和控制流还原整合到一个脚本里,就形成了一个最小可用的反混淆工具。脚本大致结构是:

  1. 读取input.js
  2. parser.parse生成 AST。
  3. 用沙箱执行提取出的解密函数。
  4. 遍历 AST,替换所有解密函数调用。
  5. 遍历 AST,识别并还原 while + switch 控制流。
  6. 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 的节点类型在不同版本里可能叫StringLiteralLiteral,建议使用@babel/types里的isStringLiteral方法,而不是手写node.type === 'StringLiteral',这样能避免新版本变动带来的兼容问题。

4.2 替换节点后生成的代码不完整

有时候用path.replaceWith替换后,生成结果里某些节点被插到奇怪的位置,甚至报错。这通常是因为替换时没有保留parent信息。Babel 的replaceWith会自动处理父子关系,但如果你手动修改 AST 数组(比如body.push(...)),就需要确保新节点有自己的locstart。建议一律使用path提供的 API,而不是直接操作节点数组。

4.3 解密函数执行结果依赖运行环境

Akamai 的混淆代码经常检测浏览器环境,比如检查window.chromenavigator.webdriver等。如果你在 Node 沙箱里执行解密函数,这些环境变量不存在,解密函数可能走不到正常分支。我的处理办法是:先分析解密函数是否引用了windowdocumentnavigator等对象,如果引用了,就在沙箱里预置好的 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 可视化工具,搜索ArrayExpressionSwitchStatementFunctionExpression大量出现的位置,快速定位混淆核心区域。
  • 第三步:先还原字符串,再还原控制流。顺序不要反。字符串还原后,控制流的 case 分支里会变成可读文本,分析状态流转会容易很多。
  • 第四步:每做一步就生成一次代码,用 diff 工具对比前后的结构变化,确认没有破坏原有逻辑。
  • 第五步:做一个小型自动化测试集。用几段已知逻辑的代码先做混淆,再对我写的反混淆脚本做验证,能有效防止还原逻辑在真实样本上翻车。

这套路径虽然不能覆盖所有 Akamai 变种,但能解决大部分样本。后续遇到新的混淆手法,只需要在这个框架上新增对应的 AST 转换模块,工作量和风险都比每次从头分析低得多。

最后分享一点个人体会:AST 操作没什么黑魔法,它就是把代码拆成积木,再按你想要的规则重新拼装。我在刚开始接触 AST 时,连path.nodepath.parent都经常搞混,后来发现只要多去astexplorer.net上手点一点,把常见节点类型和遍历方法摸熟,后面再复杂的混淆也只是时间问题。希望这篇文章能帮你少踩一些我踩过的坑。

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

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

立即咨询