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循环。经过这套流程之后,原始业务逻辑被彻底打散,任何人想从零开始读这段代码,都会觉得无从下手。
我当时面对的就是这样一副局面。做前端逆向的朋友应该都清楚,拿到这段混淆代码之后,如果只靠肉眼硬啃,或者在编辑器里 Ctrl+F 搜关键字,效率低到令人绝望,因为字符串已经不在原来的位置了,函数之间的调用关系也被打散成间接调用。那时候我意识到,必须用程序去处理这段程序。而处理代码这种结构化文本,最佳工具不是正则表达式,而是 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循环,循环体是一个switch,switch的判别表达式是一个状态变量。然后需要提取出所有 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 的混淆代码虽然结构复杂,但它的字符串解密逻辑通常是高度统一的,熟练之后,你完全可以做一个通用的解密工程化脚本,以后遇到类似的混淆代码,喂进去就能出结果。