拿到一份Akamai的混淆JS,第一反应往往是——这玩意儿真是给人看的吗?变量名全是_0x开头,字符串被塞进一个巨大的数组里,函数逻辑被拆得七零八落,甚至还有一堆死代码和冗余分支。如果你在安全分析、性能诊断或者合规审查时遇到这类脚本,最需要的不是硬着头皮读,而是找到一条能快速把混淆代码还原到可读状态的路径。AST(抽象语法树)就是这条路径的核心工具。
这篇文章我会完整梳理用AST技术解密Akamai混淆JS的实战流程,从环境准备、AST基础,到识别混淆特征、编写还原脚本,再到实际样本的还原验证。整个过程会涉及Babel工具链的使用、字符串解密、控制流平坦化还原、标识符重命名等核心环节。文章面向已经会写基本JavaScript的读者,你不需要提前精通编译原理,只要跟着流程走一遍,就能掌握一套可以复用到其他混淆样本上的解混淆方法。
1. 先搞清楚:Akamai到底在混淆什么
1.1 一段典型的Akamai混淆代码长什么样
Akamai Bot Manager这类产品,会在浏览器端下发一段JavaScript,用来做设备指纹采集、行为检测和挑战应答。为了保护这段逻辑不被轻易分析和复用,它在下发前会做多层混淆处理。最直观的表现就是代码里几乎找不到任何有意义的单词,所有标识符都被替换成类似_0x3f2a的十六进制风格名字,字符串字面量被集中抽到一个大数组里,运行时再通过下标取回。
我拆过一份典型的样本,开头长这样:
var _0x4b8a = ['\x72\x65\x64\x75\x63\x65', '\x6c\x65\x6e\x67\x74\x68', '\x66\x72\x6f\x6d\x43\x68\x61\x72\x43\x6f\x64\x65', '\x63\x68\x61\x72\x43\x6f\x64\x65\x41\x74', '...']; function _0x1f2e(_0x3c9a, _0x5b7d) { var _0x4b8a = _0x5b7d ? function() { return _0x3c9a.apply(this, arguments); } : function() { return _0x3c9a; }; return _0x4b8a; }再看函数体内的逻辑,一个简单的字符串拼接,会变成从数组里多次取值的操作,中间还穿插着无意义的条件判断和永远不会执行的分支。如果只靠人肉翻,一个几百行的脚本能看一整天。
这类混淆的核心思路是在不改变程序语义的前提下,让代码的结构变得极其难以理解。它不追求加密强度,而是追求让分析成本无限升高。所以解混淆的本质也不是解密,而是"还原"——把被伪装、被打散、被重命名的信息重新组织回我们可以理解的形式。
1.2 为什么AST是解混淆的必经之路
很多人第一反应是直接用正则去替换、去提取字符串。我最早也这么干过,结果就是修了一个补丁又冒出一个新坑。原因在于,混淆后的代码是高度结构化的,正则只能处理文本层面的模式,但它无法理解代码的嵌套关系、作用域和依赖顺序。
AST则完全不一样。它会先把源码解析成一棵结构化的树,每个节点都有明确的类型和层级关系。比如一个变量声明是VariableDeclaration,一个函数调用是CallExpression,一个字符串字面量是StringLiteral。我们可以在树的层面做精准的查找、替换、删除和重组,而且可以保证操作后的代码在语法上依然是合法的JavaScript。
Babel这个工具链把AST操作的门槛降到了极低。它提供了完善的解析器、遍历器和生成器,我们只需要写几个插件函数,就能批量处理各种混淆模式。这也是我在这篇文章里选择Babel做完整流程演示的原因——它成熟、稳定、资料多,踩坑成本低。理解了AST这套思路,后面碰到任何JS混淆变种,你都可以用同样的方式来应对。
2. 准备工作:工具链与AST基础
2.1 实操环境与依赖安装
整个流程只需要Node.js环境和npm就能跑起来。我建议用一个干净的目录来实验,避免跟你已有的工程依赖冲突。
mkdir akamai-deobfuscator cd akamai-deobfuscator npm init -y npm install @babel/parser @babel/traverse @babel/types @babel/generator这四个包分别负责解析、遍历、类型判断与节点构造、代码生成。如果是第一次接触,可能会疑惑为什么不用esprima或者acorn。说实话,acorn的解析速度更快,esprima也很经典,但它们在做AST节点替换和重组时,都远不如Babel全家桶来得方便。Babel的@babel/traverse内置了完整的遍历机制,你可以在进入某个节点时修改它,也可以在离开时再改,这对还原控制流平坦化这类需要多次遍历的场景非常关键。
安装完成之后,先准备一个入口脚本,把解析和生成的骨架搭起来:
const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generate = require('@babel/generator').default; const fs = require('fs'); const code = fs.readFileSync('input.js', 'utf-8'); const ast = parser.parse(code); // 这里后面会插入各种还原插件 const output = generate(ast, { compact: false }).code; fs.writeFileSync('output.js', output);这样一个最小闭环就完成了。后面所有的解混淆逻辑,都是在parse和generate之间操作这棵AST树。
2.2 把混淆代码变成一棵AST树
Babel解析出来的AST,本质上是一个纯对象结构。每个节点都有type字段,比如Program、FunctionDeclaration、MemberExpression,还会有对应的子节点和属性。
看一个最简单的例子。源码是:
var a = 'hello';解析成AST后,结构会变成:
Program body: [ VariableDeclaration declarations: [ VariableDeclarator id: Identifier (name: "a") init: StringLiteral (value: "hello") ] kind: "var" ]有了这棵树,我们就可以精确地找到代码里的每一个字符串、每一个变量名、每一次函数调用。混淆脚本里常见的_0x1234['length']这种写法,在AST里其实就是MemberExpression,其中property是一个StringLiteral。我们只需要判断这个StringLiteral的内容是否可以被安全替换成标识符,就能把它还原成_0x1234.length。
不过AST也有一个门槛:你刚开始操作时,需要频繁地查看节点结构。我的习惯是先写一个简单的打印脚本,把某个节点的JSON.stringify输出出来,对照着看一遍它有哪些属性。这一步能帮你快速建立对AST的感觉,后面的所有编写效率都会高很多。
3. 核心流程:四步还原法
3.1 第一步:精准识别混淆特征
拿到代码后不要急着写脚本,先做一次"体检"。我用Akamai样本的经验是,重点看三个特征:
- 是否有一个巨大的字符串数组,并且有一个专用的解码函数来访问它
- 函数内部是否存在大量
while/switch嵌套,或者浓厚的if...else死代码分支 - 标识符是否全部变成了
_0x开头,或者使用了十六进制转义的字符串
这三个特征分别对应三种最常见的混淆手段:字符串数组加密、控制流平坦化、标识符混淆。实际操作时,Akamai往往会把它们叠加在一起,还会在数组访问函数外面再套一层加密,让你不能简单地通过"替换下标为字面量"来还原。
识别这一步不要靠肉眼硬看,可以写几个简单的统计脚本。比如统计所有Identifier节点里_0x开头占的比例,统计所有StringLiteral节点里包含转义字符的数量。这些数字能直接告诉你混淆的力度和侧重点。
3.2 第二步:字符串解密还原
字符串数组加密是Akamai混淆里最基础也最关键的一环。通常的模式是:
var _0xarr = ['\x68\x65\x6c\x6c\x6f', '\x77\x6f\x72\x6c\x64']; function _0xget(_0xidx) { return _0xarr[_0xidx]; } var msg = _0xget(0) + ' ' + _0xget(1);这个例子里,_0xget(0)在运行时能取到'hello',_0xget(1)能取到'world'。但在静态分析时,我们看不到msg的值。
要还原这一类混淆,思路是:找到数组的定义,找到访问数组的函数,然后模拟这个函数的执行过程。这里有一个很关键的原则——只有当函数的入参全部是字面量时,才适合做静态执行。如果某个调用的参数来自一个变量,那我们就不能贸然替换,否则会改变程序语义。
在Babel里,可以这样写一个基础插件:
const stringArrayName = '_0xarr'; const decryptFunctionName = '_0xget'; const stringArray = {}; // 第一步:收集数组内容 traverse(ast, { VariableDeclarator(path) { if (path.node.id.name === stringArrayName) { // 这里需要判断 init 是 ArrayExpression,然后遍历 elements 收集值 } } });收集完数组内容后,再遍历所有CallExpression,如果调用的是_0xget且参数是数字字面量,就把它替换成对应的字符串字面量。这一步做完,代码里的可读性会提升一大截,字符串全部变成明文了。
实际操作中,Akamai会把数组拆成多个,还会对下标做异或运算。比如_0xget(0x3f ^ 0x2a)这种写法。处理办法是递归地计算参数表达式,如果参数表达式里全部是字面量和运算符,就先用一个简单的表达式求值器算出结果,再替换。这里要特别小心,不要试图在浏览器里直接eval它,而是自己写一个只支持常用运算符的计算函数,避免引入执行风险。
3.3 第三步:控制流平坦化的还原
字符串解密只是热身,真正让代码难以阅读的是控制流平坦化。它的原理是把原来的if...else、while、for等分支结构,全部拍平成一个while循环加switch分发的结构。从AST上看,原来的块语句被拆散成一个大的状态机,每次循环根据一个分发变量跳转到不同的case。
我见过的最小化结构是这样的:
var _0xstate = 3; while (true) { switch (_0xstate) { case 3: doSomething(); _0xstate = 5; break; case 5: doAnotherThing(); _0xstate = 0; break; case 0: return result; } }还原控制流平坦化是解混淆里最难的一步。我的实践方法是基于路径跟踪的状态机重组:
- 先找到
while(true)节点,并定位到它内部的switch。 - 找到分发变量的初始赋值语句,把它作为起始状态。
- 遍历每个
case,记录该case的执行语句,以及执行完毕后分发变量被修改成什么值。 - 根据状态跳转关系,把散落的语句块重新拼接成顺序执行的代码,插入到原
while节点所在的位置。
这听起来简单,但实际落地时有几个坑。Akamai会在每个case里插入大量不相关的死代码,比如永远为true的条件判断、不会执行的break,甚至会把分发变量的赋值藏在某个深层嵌套函数里。这些都需要在重组前做清洗。
我在自己的还原脚本里,会先用一个函数分析当前case的内容里是否包含对分发变量的赋值。如果存在多层嵌套,就递归地深入查找。同时,我会把识别出来的每个case的执行内容复制到一组BlockStatement里,最后用这些BlockStatement替换掉原来的while节点。这个方案我实测了很多次,最终处理效果都还挺稳定。
3.4 第四步:标识符重命名与美化输出
前面的还原做完后,代码语义已经清晰了很多,但变量名还是_0x开头,阅读起来依然难受。这一步做两件事:
- 将有意义的函数调用、变量声明,在AST层面尝试还原成更直观的名称。比如某个函数内部调用了一段设备指纹检测逻辑,如果能在上下文里识别出特征,可以手动维护一张映射表,把
_0x3f2a改成getFingerprint。 - 对剩余没有明确语义的标识符,统一重命名为
a、b、c这样连续的短名字,减少视觉噪音。
需要注意,重命名必须保证同一作用域内不出现冲突。Babel的path.scope机制可以帮我们生成唯一的名字,避免手动维护计数器。
最后使用@babel/generator输出代码时,可以开启compact: false来美化格式,这样代码会按照标准缩进输出,可读性会大幅提升。我还会开具retainLines: false,因为原始混淆代码的行信息基本没有利用价值,保留反而会让格式混乱。
4. 实操记录:还原一份真实Akamai样本
4.1 样本概览与混淆模式统计
我找了一份比较典型的Akamai Bot Manager脚本作为测试样本,文件大小约60KB,格式化后大概3500行。用脚本统计了一下,_0x开头的标识符占全部标识符的94%,字符串数组一共有两个,每个数组长度都超过了300。函数内部的控制流平坦化结构出现了7处,其中最大的一个状态机包含了40多个case。
这份样本还追加了一层"自保护":数组访问函数内部用了三异或运算,而且数组本身在程序开头会被一个立即执行函数重新排列。也就是说,你不能直接静态地按顺序读数组内容,必须先找到那个洗牌函数,模拟它执行之后才能得到真实的数组映射关系。
4.2 核心还原脚本的完整实现
整个还原脚本我分成四个模块,分别对应上面的四步。因为篇幅有限,这里重点展示字符串数组解密这个模块的核心逻辑。
const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generate = require('@babel/generator').default; const t = require('@babel/types'); function evaluateLiteral(node) { if (t.isNumericLiteral(node) || t.isStringLiteral(node) || t.isBooleanLiteral(node)) { return node.value; } if (t.isBinaryExpression(node)) { const left = evaluateLiteral(node.left); const right = evaluateLiteral(node.right); if (left === undefined || right === undefined) return undefined; switch (node.operator) { case '+': return left + right; case '-': return left - right; case '*': return left * right; case '^': return left ^ right; default: return undefined; } } return undefined; } function collectArrayContent(path) { const arr = {}; if (t.isArrayExpression(path.node.init)) { path.node.init.elements.forEach((el, idx) => { if (t.isStringLiteral(el)) arr[idx] = el.value; }); } return arr; } function decryptStringArray(ast) { let stringMap = {}; // 第一步:找出所有数组定义 traverse(ast, { VariableDeclarator(path) { if (t.isArrayExpression(path.node.init)) { const values = collectArrayContent(path); stringMap[path.node.id.name] = values; // 保留数组定义,后面处理函数会用到 } } }); // 第二步:解析数组访问函数的调用 traverse(ast, { CallExpression(path) { const callee = path.node.callee; const args = path.node.arguments; if (t.isIdentifier(callee) && stringMap[callee.name]) { const idx = evaluateLiteral(args[0]); if (idx !== undefined && stringMap[callee.name][idx] !== undefined) { path.replaceWith(t.stringLiteral(stringMap[callee.name][idx])); } } } }); }这段代码的思路是:先扫描所有数组变量,把下标和字符串值的对应关系存到stringMap里;再扫描所有函数调用,如果被调用的函数名在stringMap里,并且参数能求值为数字,就直接替换成对应的字符串字面量。
需要注意,这个简化版本没有处理"数组洗牌"和"多级解密函数"的情况。实际样本里,数组在定义之后还会被一个自执行函数重排,所以单纯按照顺序去取下标是不对的。我的处理方法是先找到洗牌函数,在AST层面模拟执行它对数组的重排,然后再做字符串替换。具体来说,就是先创建一个临时的数组副本,遍历洗牌函数体内的赋值语句,逐步更新副本内容,最后用副本覆盖原始数组的元素顺序。
4.3 还原效果与结果对比
运行完整个脚本之后,我看到的效果非常直观:
- 字符串明文率从几乎为0提升到接近100%,所有十六进制转义的字符串都变成了可读内容。
- 控制流平坦化的7处结构全部被消除,替换成了顺序的
if/else或者直接平铺的语句块。 - 代码总行数从3500行下降到约1800行,其中很大一部分是去除死代码和冗余函数后的结果。
我把还原后的代码重新生成到一个新文件里,打开看第一屏,已经能清晰地看到函数名、字符串内容和基本的业务逻辑了。当然,要达到完全看懂的水平,还需要结合运行时调试来确认某些变量的值,但相比直接用_0x堆砌的原始包,这已经是从"天书"到"能读懂"的巨大跨越。
5. 常见问题与避坑指南
5.1 高频报错与排查思路
报错1:Property left of BinaryExpression expected node to be a Expression
这个报错常见于尝试构建或替换表达式时,左值或者右值传入了不完整的数据。排查思路是先打印当前节点看看是不是undefined,确认无误后再构造。我自己的经验是,遇到这类问题先检查遍历时对节点类型的判断,很多情况下是因为把Statement当成Expression用了。
报错2:替换后生成代码语法错误
这种问题通常是替换节点时破坏了语法结构。比如把一个Expression直接替换成了BlockStatement,或者把StringLiteral替换进了本该是Identifier的位置。解决办法是在替换前用t.isXxx做一次类型校验,也可以用@babel/parser重新解析替换后的代码,看能否通过语法解析。
报错3:字符串解密不生效
最常见的原因是数组访问函数内部有多层封装,或者参数里包含函数调用而不是纯字面量。排查思路是先确认数组是否被洗牌过,再确认调用参数能否被evaluateLiteral求值。如果参数是_0x3f2a(0x10)这类嵌套调用,需要递归处理。
报错4:死代码清理后变量未定义
清理死代码时,有些变量可能只在死代码分支里被声明,一旦删除分支,其他地方引用了该变量就会报错。所以清理前最好先做一次全量引用统计,或者使用path.scope检查变量是否只有声明没有引用,确认安全后再删除。
5.2 几条真正值钱的实战教训
- 不要一上来就写通用还原框架。我见过很多人想一步到位做一个全自动、能处理所有混淆的框架,结果写到一半就卡住了。正确的做法是先针对手头样本,做一个最小可用的脚本,跑通之后再考虑抽象通用逻辑。混淆技术更新很快,通用框架反而容易变成一块巨大的维护负担。
- 先备份原始样本。每次操作前都保留一份原始文件,因为还原脚本很可能会出错,甚至把代码改坏。我在实践时会把原始样本按版本号归档,这样出了问题可以快速对比是哪一步导致的。
- 善用
path.skip()和path.stop()控制遍历范围。遍历整棵AST的开销不小,如果知道某个子树肯定不需要处理,直接跳过能显著提升脚本执行速度。特别是大型样本,遍历优化可以省下大量时间。 - 尽量别在Node环境里执行混淆代码的逻辑。有些场景下,直接在脚本里用
eval执行混淆函数去取解密结果,看起来省事,但会带来安全风险。原因是你无法确定这段代码在运行时会访问什么全局变量,或者触发什么隐藏副作用。更安全的做法是维护一个小型表达式求值器,只支持自己需要的运算符。
最后再分享一个我自己的习惯:处理完一段混淆代码之后,不要把还原结果直接扔掉,我会把它压缩存档,并且在旁边留下一份还原过程的技术笔记。因为同一家厂商的混淆脚本通常会有固定的代码生成器,你这次总结出的模式,很可能在下一次分析里派上大用场。每次做这种逆向还原,实际上都是在和混淆器的开发者"打交道",你越了解他们的生成习惯,你的还原效率就会越高。