
1. 从一份几乎不可读的 JS 说起Akamai 混淆代码长什么样有段时间我需要研究某个站点的前端加密逻辑按 F12 打开 Sources 面板随手点开一个 JS 文件屏幕上出现了一坨我至今记忆犹新的东西——单行 30 多万字符变量名全是_0x2a3f4b这种十六进制样式开头是一个巨大的数组里面塞满了\x63\x6f\x6e\x73\x6f\x6c\x65这类字符串再往下是一层套一层的自执行函数。那种感觉就像打开一本书发现每一页都是摩斯密码根本没法正常阅读。这就是 Akamai 混淆过的 JS 代码。Akamai 这家公司的主业是 CDN 和 Web 防护他们的 Bot Manager 产品会在页面里注入一段动态生成的 JS用来做浏览器指纹采集、行为检测、请求合法性校验等一系列操作。为了让安全研究人员和爬虫作者没那么容易看懂这段逻辑他们会把代码做多层混淆处理变量名和函数名全部替换成无意义的_0x标识符字符串字面量抽离到一个数组里统一编码函数调用改写成数组下标形式甚至把完整的条件分支逻辑压平成一个巨大的switch while循环。经过这套流程之后原始业务逻辑被彻底打散任何人想从零开始读这段代码都会觉得无从下手。我当时面对的就是这样一副局面。做前端逆向的朋友应该都清楚拿到这段混淆代码之后如果只靠肉眼硬啃或者在编辑器里 CtrlF 搜关键字效率低到令人绝望因为字符串已经不在原来的位置了函数之间的调用关系也被打散成间接调用。那时候我意识到必须用程序去处理这段程序。而处理代码这种结构化文本最佳工具不是正则表达式而是 AST——抽象语法树。这篇文章我会完整记录我是怎么利用 AST 把 Akamai 混淆 JS 从不可读逐步变成可读的整个过程包括思路、工具链、核心代码、踩坑点以及最后如何验证解密结果是否正确。如果你也在做前端 JS 逆向、爬虫分析、安全研究或者纯粹对混淆对抗感兴趣这篇文章应该能给你一套可以直接落地的分析流程。2. 为什么是 AST正则表达式在混淆代码面前的失效现场先说一个很多人问过的问题解密混淆代码用正则表达式匹配替换不就行了比如数组字符串都是xxx形式用正则把_0x4b2f(0x1)提取出来再查数组替换成真实字符串很简单啊确实对于最简单的一层混淆正则脚本可以应付。我第一次尝试的时候也是这么干的——用 Python 写了个正则提取所有_0x4b2f(0x...)调用点手动把数组内容抠出来然后做一个字符串替换。跑完一看页面确实能跑但代码仍然可读性极差因为还有大量问题正则根本解决不了。举个例子混淆器会把函数名改成_0x5a1d2c并且全部混在一起变量名、函数名、对象属性名长得一模一样正则没有能力区分哪个是函数名哪个是变量名哪个是对象属性名。再比如字符串解密函数的调用是可以嵌套的_0x1234[_0x5678]这种形式或者_0x1234(_0x5678(0x1))正则处理嵌套表达式非常痛苦而且很容易误替换掉字符串数组定义本身导致代码直接崩掉。AST 的方式则完全不同。AST 把一段 JS 代码解析成一棵结构化的语法树代码里的每一个元素——标识符、函数声明、变量声明、调用表达式、条件语句——在树里都有明确的节点类型和父子关系。你可以在树上精准地找到所有符合特定模式的 Identifier 节点然后只修改它们不会碰到不该碰的地方。这就像正则像是在文本里用通配符乱抓而 AST 像是拿着一份标注了每个词性成分的解剖图做手术精度完全不在一个量级。具体到工具上我推荐直接使用 Babel 的工具链做解混淆。Babel 本质上就是一个用 JS 写的 JS 编译器它提供了三块核心能力babel/parser把 JS 源码解析成 AST 对象。babel/traverse babel/types遍历 AST识别和修改节点。babel/generator把修改后的 AST 重新生成 JS 代码。整套工具链成熟稳定社区教程多遇到不认识的节点类型直接去 AST Explorer 网站上对照就行。下面我写的所有解密脚本都是基于这套工具链的。3. 解密 Akamai 混淆的完整实操链路从字符串还原到代码可读解密混淆代码不是一个跑完一个脚本就结束的过程而是一层一层拆洋葱。我按照实际操作的顺序把整条链路拆成了四个核心步骤。3.1 第一步先分析混淆特征再动手写第一行代码很多新手拿到混淆代码恨不得立刻写个脚本把所有变量名全部重命名。我建议先别急着动代码花十分钟把代码的特征摸一遍。这一步决定了你后面脚本怎么写。一份典型的 Akamai 混淆 JS通常有几个鲜明的特征代码最顶部是一个大的数组声明变量名形如var _0x4b2f [...]数组元素是十六进制转义后的字符串。紧接着是一个解密函数形如function _0x1234(_0x5678)内部逻辑是从前面那个数组里按下标取值下标参数可能是数字也可能是十六进制字符串。再往后是一个自执行函数形如(function(_0x2222, _0x3333) { ... }(_0x4b2f, 0x2c8f))里面会做数组元素的洗牌操作——把数组顺序打乱。这一层很关键因为如果直接拿数组原始顺序去解密字符串得到的值全是错的。整个代码主体被包裹在一个大自执行函数里防止污染全局作用域。几乎所有的字符串字面量都被替换成_0x4b2f(0x1)这种调用表达式。数字常量会被转换成十六进制比如0x1f、0x64这种。摸清楚了这些特征你的解密思路才算是有了方向。我当时在 AST Explorer 上把代码粘贴进去逐个节点展开对照确认了上述结构之后才开始写脚本。3.2 第二步绕开数组洗牌先还原字符串字面量字符串还原是整条链路上最优先做的一步。想想看如果代码里的console变成了_0x4b2f(0x1)你的第一反应肯定是想看看0x1到底对应哪个字符串。字符串还原之后代码的可读性会立刻上一个台阶。实际操作中第一个大坑是数组洗牌逻辑。Akamai 的混淆器会定义一个初始数组然后通过一个自执行函数把数组元素重新排序。如果你只是静态地把初始数组抠出来再用解密函数去取值得到的字符串是错乱的。我当时的具体做法是这样的第一步先找到那个解密函数分析它的逻辑。Akamai 的字符串解密函数通常很简单核心就是var _0xaaa _0xbbb这里的_0xbbb是数组名最终返回值是arr[index]或者经过位移操作后的arr[index - shift]。第二步写一个 Babel 插件在 AST 里定位到解密函数 数组这两个顶层声明节点然后在当前进程中执行解密函数得到一份完整的数组下标 - 真实字符串的映射表。第三步遍历整个 AST把形如_0x4b2f(0x1)或_0x4b2f(0x1, 0x2)的调用表达式替换成对应的真实字符串字面量。这里有一个很关键的技术点怎么在脚本里安全地执行解密函数我是把数组定义和解密函数的源码提取出来拼成一个临时的 JS 字符串用 Node 的vm模块执行拿到解密函数引用然后在 AST 遍历时直接调用它。注意在拼接的时候要把数组和解密函数后面跟着的洗牌自执行函数也一起提取进去保证数组顺序是经过洗牌之后的真实顺序。大致代码如下const vm require(vm); const parser require(babel/parser); const traverse require(babel/traverse).default; const t require(babel/types); const generate require(babel/generator).default; function extractDecryptor(ast) { let arrayNode, decryptorNode, shuffleNode; traverse(ast, { VariableDeclarator(path) { if (path.node.id.name.startsWith(_0x) t.isArrayExpression(path.node.init)) { arrayNode path.node; // 找到字符串数组 } }, FunctionDeclaration(path) { if (path.node.id.name.startsWith(_0x) arrayNode) { // 通过分析函数体判断它是不是解密函数 decryptorNode path.node; } }, ExpressionStatement(path) { // 找到紧随其后的洗牌自执行函数 if (shuffleNode undefined path.node.type ExpressionStatement) { shuffleNode path.node; } } }); const code generate(t.program([ t.variableDeclaration(var, [arrayNode]), t.functionDeclaration(decryptorNode.id, decryptorNode.params, decryptorNode.body), shuffleNode ])).code; const sandbox {}; vm.createContext(sandbox); vm.runInContext(code, sandbox); return sandbox[decryptorNode.id.name]; // 返回解密函数 }拿到解密函数之后再遍历 AST 做替换把_0x4b2f(0x1)这种调用变成真正的字符串traverse(ast, { CallExpression(path) { const callee path.node.callee; if (t.isIdentifier(callee) callee.name decryptorName) { const result decryptorFunction(path.node.arguments[0].value); if (typeof result string) { path.replaceWith(t.stringLiteral(result)); } } } });这一步跑完代码里的字符串调用表达式会全部变成可读的字符串字面量阅读信息量瞬间提升一大截。3.3 第三步还原标识符——重命名_0x变量和函数字符串还原之后紧接着的问题就是满屏的_0x变量。所有变量名、函数名都是随机生成的互相之间没有任何语义关系。这种可读性障碍不解决后续逻辑分析依然举步维艰。标识符还原我分两层来做。第一层是内置 API 的识别。混淆代码里虽然变量名叫_0xabc但它本质上可能是在调用window.localStorage或者document.createElement。这种识别逻辑比较简单——我维护一个映射表把代码里常用的浏览器 API 名称和混淆后的名称做对应。具体做法是观察代码里_0xabc.call(window, ...)或者new _0xabc(...)这类调用的上下文结合运行时行为判断它是在调用哪个 API。第二层是把混淆标识符重命名为有意义的英文单词。这一步的难点在于脚本无法自动理解每个变量的语义所以我会优先把变量按照作用域顺序重命名为var_0、var_1这种样式。这样至少能区分不同变量不再是一堆长得几乎一样的_0x乱码。实现上我需要遍历 AST 里所有Identifier节点然后通过path.scope作用域信息判断该标识符是变量声明还是引用再统一替换。这里要注意不能直接全局替换同名文本因为不同作用域里可能是不同的变量。Babel 的scope.rename方法可以很好地处理这个问题traverse(ast, { FunctionDeclaration(path) { if (path.node.id.name.startsWith(_0x)) { path.scope.rename(path.node.id.name, func_ funcCounter); } }, VariableDeclarator(path) { if (path.node.id.name.startsWith(_0x)) { path.scope.rename(path.node.id.name, var_ varCounter); } } });这一步跑完之后代码从满屏乱码变成了结构化乱码虽然变量名还是没有语义但至少能通过作用域区分了。再配合浏览器 DevTools 的断点调试我已经能把关键逻辑逐行解析出来。3.4 第四步控制流平坦化的处理思路字符串还原、标识符重命名做完Akamai 混淆的第一梯队障碍基本扫清。但如果你试图完整读通整个 JS 的逻辑还会遇到一个更恶心的东西——控制流平坦化。控制流平坦化是高级混淆器最喜欢用的手段。原本一个正常的 if-else 分支代码会被改造成一个巨大的while (true) { switch (state) { ... } }结构用一个状态变量来控制执行顺序。所有的真实逻辑都被打散成独立的小块每个小块执行完会更新状态变量跳到下一个小块。这种代码读起来非常反直觉因为你看到的是一个永无止境的循环而不是一个清晰的条件分支。Akamai 的控制流平坦化不算是业界最复杂的但也足够让人头疼。处理这种方式有两种思路第一种思路是全自动还原。做法是先定位到分发器——一般是一个while循环循环体是一个switchswitch的判别表达式是一个状态变量。然后需要提取出所有 case 分支作为独立的基本块分析每个基本块的入口状态值和出口状态值最后按照状态值的顺序把基本块重新串成线性顺序。这个过程说起来简单实现起来非常繁琐要处理死代码、无条件跳转、赋值语句副作用等问题。我当时用 Babel 写了个半成品脚本能处理 60% 左右的情况剩下的还是靠人工补充。第二种思路是绕过去。如果你分析混淆代码的目的不是为了还原它的本来面目而是为了找到某个特定功能的位置那完全不需要做全量反平坦化。我的实际做法是先把字符串都还原好然后用搜索关键词的方式定位逻辑附近。比如搜索UA、/_sec/这类字符串找到相关代码块再顺着封装它的基本块查看状态变量的赋值关系只追踪和目标字符串相关的几条路径这样能在不做全量还原的情况下理解关键逻辑。这里我给个建议控制流平坦化的自动化还原需要的时间成本很高如果你不是要做完整的代码级复刻建议采用混合方案——先做字符串和标识符还原然后针对目标逻辑做局部追踪最后再决定要不要投入做反平坦化。4. 解密过程中我踩过的四个翻车细节解密这个活跑通主流程只是一半真正的魔鬼都在细节里。下面这几个坑我几乎每一个都踩过写出来希望大家绕开。4.1 字符串数组解密顺序错了全盘皆崩开头提到过 Akamai 混淆代码里有一个数组洗牌逻辑。数组在定义之后会被一个自执行函数重新排序洗牌逻辑本身可能依赖一个初始偏移量。如果你在提取数组的时候只取了原始定义没把洗牌函数一起带上那么解密函数执行时拿到的是洗牌前的数组通过下标取出的字符串全是乱的而且编译出的 JS 可能会报 undefined 错误整个过程直接推倒重来。我在第一次跑脚本的时候就栽在这上面。检查了很久才发现单独提取数组定义和解密函数会导致代码上下文不完整。解决办法前面也提到过把数组、解密函数、洗牌自执行函数这三部分作为一个整体提取出来在 VM 里一起执行保证数组顺序是最终的真实顺序。4.2 全局重命名标识符导致的作用域污染在做变量名重命名时如果直接在Identifier节点上做文本替换很容易导致作用域污染。举个例子两个不同的函数内部都有_0xabc变量但它们在不同的作用域重命名后变成同一个名字就会互相干扰。更危险的是如果混淆代码里有对象属性名也是_0x开头你错误地把属性名也替换了代码直接报错。后来我改用path.scope.rename方法这个方法会智能地处理作用域关系重命名一个变量时会把整个作用域链里所有引用它的标识符一起改掉而且会避免重名冲突。用这个 API 之后我基本没有遇到过作用域污染的问题。4.3 解密函数的参数可能是表达式不只是字面量字符串还原的时候我一开始默认_0x4b2f(0x1)的参数是字符串字面量直接用path.node.arguments[0].value取下标值。结果跑了一段时间遇到形如_0x4b2f(_0x5678(0x2))的情况——参数本身又是一个解密函数的调用。这种嵌套场景下直接取值得到的是undefined字符串还原直接漏掉根源在于调用点本身还没被还原。后来我改用递归还原的策略先遍历 AST遇到 CallExpression 时如果参数也是 CallExpression就先递归替换内层调用等内层变成字符串字面量之后再执行外层替换。这样能覆盖所有嵌套场景。4.4 不要一开始就追求完美还原我刚上手的时候总想着写一个全自动脚本一次跑完把混淆代码完美还原成接近源码的样子。结果浪费了大量时间在处理各种边缘情况上而目标逻辑一点没分析到。后来我调整了策略先跑最基础的字符串还原和标识符重命名输出一个能读懂的中间版本然后直接用 DevTools 跑起来断点调试关键逻辑。发现看不懂的地方再回到脚本里补充对应的处理逻辑。这种渐进式解密的方式效率要高得多。我建议你也在流程里保持中间版本的输出每次修改都保存一下方便对比和回退。5. 解密成果怎么验证三招判断你的还原是否成功解密脚本跑完之后最关心的一个问题就是还原出来的代码到底对不对如果只是看起来可读了但运行起来报错那等于白干。我总结了三个维度的验证方法。首先是行为验证。解密后的代码无论如何修改它的行为和原始代码必须一致。我会把还原后的 JS 放到 Node.js 里执行一遍或者用浏览器 DevTools 的 Local Overrides 功能在真实页面里替换原始 JS观察页面功能是否正常。Akamai 的这段 JS 在页面加载时会采集浏览器指纹并发送给服务端如果解密后这些行为还能正常发生说明核心逻辑没有被破坏。其次是断点调试验证。把还原后的代码在浏览器里跑起来在关键调用点打断点。比如我之前追踪的目标是某个请求校验逻辑我会在请求发出前拦截断点检查当前的参数值、请求头是否符合预期。如果参数值和原始环境一致说明字符串还原和标识符重命名没有改变变量语义。最后是差异比对抽查。还原后的代码可能在整体结构和原始代码差别很大但关键逻辑应该保持一致性。我会挑几个关键的函数调用比对它的调用参数、返回值和原始代码在运行时输出的结果是否一致。我之前写了个小工具会在原始混淆代码和解密后代码里分别打印同一函数的执行结果然后 diff 输出。两边结果一致基本可以放心了。这里分享一个我在实际使用中的小技巧在解密过程中保存多个中间版本按时间顺序命名为decoded_v1.js、decoded_v2.js。做验证时如果某一步的还原操作导致了行为异常可以直接对比前后版本的差异快速定位是哪一步出了问题。这比从头重新跑一遍脚本要省时间得多。而且经过反复验证我发现Akamai 的混淆代码虽然结构复杂但它的字符串解密逻辑通常是高度统一的熟练之后你完全可以做一个通用的解密工程化脚本以后遇到类似的混淆代码喂进去就能出结果。