最近在分析某财经投资社区网页端接口时,我在打包后的JS里注意到一个以前没见过的函数名:md5__1038。单看命名,它很像webpack模块编号加混淆后缀的产物,又像一个MD5封装函数。顺着它往下追,我走完了一条很典型的JS逆向分析路径:用XHR断点锁定加密边界,用AST工具把混淆代码还原成可读结构,最后识别出算法本质并复现了参数签名规则。这篇记录一下完整链路,希望对做前端安全研究、接口协议分析或算法分析的同学有参考价值。
这类问题说到底分三步:第一,找到加密函数在哪里;第二,把它还原成能读懂的代码;第三,识别算法并验证。难点几乎都集中在第二步。现在的站点普遍会做webpack打包、变量名压缩、字符串编码、控制流平坦化,甚至自研混淆器。下面从最外层开始拆。
1. 从函数名 md5__1038 猜出的事:混淆模块的结构特征
1.1 webpack模块与混淆后缀的命名套路
现代前端站点里,业务JS基本都经过webpack打包。打包前各个文件叫啥已经不重要,webpack会把它们编译成模块ID,函数名、变量名也会被压缩工具改写成短名。于是你会看到__md5__1038、_0x4c2a、a3b4c5这类命名。它们一般不是开发人员原本的语义,而是压缩混淆之后的“幸存者”。
比如md5__1038,我的第一反应是:这是一个语义名,后面跟着模块ID或混淆序号。常见的混淆器会在函数名后面追加数字后缀来保证唯一性,比如md5__1038、md5__1039、md5__1040。所以看到这个名字,可以合理猜测它跟MD5算法相关,但不能完全当真——压缩代码里的函数名经常是工具随机生成的,也可能是个误导,真正的MD5逻辑藏在它调用的其他函数里。
判断依据通常有三点:
- 函数内部出现
hex、digest、hash之类的字符串字样; - 函数返回值被直接用于请求参数,比如
sign、password、token; - 与标准MD5常量表存在可匹配的特征,比如
0x67452301。
我这次遇到的情况恰好三条都命中。顺着函数名搜索,很快在调用点附近发现了同一个请求里的瞬间时间戳和随机数。
1.2 先别急着反混淆,确认函数与哪些请求相关
拿到一个几十万行的bundle文件,千万别直接扔进Babel做反混淆,处理速度慢、输出结果也难读。先把目标缩小。
首先要弄清楚md5__1038被谁调用。在Chrome DevTools的Sources面板里打开压缩后的JS,点左下角格式化按钮,然后搜索md5__1038,查看所有调用点。调用点所在的模块一般就是业务模块,比如登录模块、评论模块、搜索模块。
其次要弄清楚它和哪些请求参数相关。在Network面板里过滤XHR请求,看登录、注册、评论这类接口的请求体里有没有sign、password、timestamp字段。凡是有这些字段的接口,十有八九跟这个函数有关。
我这里遇到的场景是这样的:登录请求的body里有一个sign字段,还有一个被编码过的密码字段,两个参数都来自md5__1038。确认关联关系之后,需要继续往下定位更精确的加密边界。
2. 定位加密边界:XHR断点与调用栈是最高效的入口
2.1 用URL断点抓登录请求的调用链
很多新手一上来就翻动整个bundle文件找加密函数,效率极低。正确姿势是直接在网络层下断点,让浏览器帮你画出从事件触发到HTTP请求发出的完整路径。
操作步骤:
- 打开DevTools,切到Sources面板,右侧找到
XHR/fetch Breakpoints。 - 点加号,添加一条URL规则,比如包含
login或api/user的请求。 - 在页面上触发登录,断点会在
XMLHttpRequest.send附近命中。 - 打开右侧Call Stack面板,逐层往下看,找到业务代码那一帧。
- 点进业务代码帧,打印出它携带的参数,通常就能看到加密函数的调用点。
这个方法比全文搜索快得多,因为调用栈天然给出了从页面事件到HTTP请求的完整链路。如果站点不是用XHR而是fetch,原理一样,断点类型换成fetch即可。
2.2 在压缩代码里做关键词搜索的几条经验
有时候调用栈里的帧是匿名函数或压缩后的短名,光靠调用栈还是辨识不出业务逻辑。这时可以回到代码搜索。
搜索关键词有优先级。不要一上来就搜password,压缩后变量名可能是p、a、_0x2f;但请求参数名里的字符串password通常会原样保留,因为服务端需要解析。所以要搜的是:
sign、_sign、x-signpassword、pwdtimestamp、tstokenmd5、hex_md5、CryptoJS
搜索结果多的时候,建议把bundle保存到本地,用VS Code或ripgrep搜索,比在浏览器里翻快得多。再把每个匹配点的上下文行复制出来,逐段“摘录”,后面做AST反混淆时就只处理摘录片段,工作量直接降一个量级。
我在这个阶段还会做一件事:在断点处把md5__1038的实参打印出来,保存一组已知输入和输出。这组数据是后面验证算法的关键样本。
3. AST反混淆实战:用Babel把压缩代码还原成可读结构
3.1 先理解AST对于混淆代码意味着什么
AST是抽象语法树,它把JavaScript源码解析成一棵结构化的树。混淆代码不管长得多难看,只要是合法的JavaScript,就能被解析成AST。Babel这个工具本来是用来做代码转换的,但它的@babel/parser、@babel/traverse、@babel/generator三个包合起来,正好可以当反混淆工作台用。
反混淆的本质就是一系列AST变换:
- 把复杂的
CallExpression替换成它实际返回的字面量; - 把只赋值一次的变量直接代入后续引用;
- 把散落在数组里的字符串解密成明文;
- 把
switch-case控制流平坦化还原成线性语句; - 删除所有没有被引用的死代码。
关键点:每一次替换都要保证语义不变。Babel的遍历器允许你在节点上做非常精准的修改,比正则替换安全得多,也更适合处理嵌套复杂的压缩代码。
3.2 字符串解密与常量折叠的脚本示例
最常见的混淆特征是字符串数组加解码函数。代码大概长这样:
var _0x4c2a = function(x) { var t = ['\x68\x65\x6c\x6c\x6f', '\x77\x6f\x72\x6c\x64']; return t[x - 0x1]; };调用时写_0x4c2a('0x1'),实际返回的是hello。反混淆思路是:先把_0x4c2a函数体里的字符串数组拿出来,在本地执行一遍,得到key到value的映射表,然后在AST遍历中把所有满足条件的调用替换成字符串字面量。
示例脚本:
const fs = require('fs'); const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generate = require('@babel/generator').default; const t = require('@babel/types'); const code = fs.readFileSync('bundle.js', 'utf-8'); const ast = parser.parse(code, { sourceType: 'unambiguous', plugins: ['dynamicImport', 'optionalChaining', 'nullishCoalescingOperator'] }); // decodeMap 需要在外部先计算好 function replaceDecodeCalls(ast, decoderName, decodeMap) { traverse(ast, { CallExpression(path) { const callee = path.node.callee; if ( t.isIdentifier(callee) && callee.name === decoderName && path.node.arguments.length === 1 && t.isStringLiteral(path.node.arguments[0]) ) { const key = path.node.arguments[0].value; if (Object.prototype.hasOwnProperty.call(decodeMap, key)) { path.replaceWith(t.stringLiteral(decodeMap[key])); } } } }); } replaceDecodeCalls(ast, '_0x4c2a', decodeMap); fs.writeFileSync('bundle.deob.js', generate(ast).code);生成decodeMap的办法很朴素:把解码函数和字符串数组提取出来,在Node里调用一遍,把所有输入输出打印出来。不需要手算,也不需要逆向那个数组的生成算法。
字符串替换完,接着做常量折叠。压缩代码里到处都是var _0x2e = 0x14;,后续引用_0x2e的位置都可以直接替换成20。Babel的scope绑定可以拿到每个引用的位置,所以实现起来不难。这一层做完,代码的可读性会有明显提升。
3.3 控制流平坦化与死代码清理的思路
控制流平坦化长得像下面这样:
function demo(x) { var state = 0; while (true) { switch (state) { case 0: x += 1; state = 2; break; case 2: x *= 2; state = 1; break; case 1: return x; } } }这里逻辑其实是顺序执行的:先x += 1,再x *= 2,最后返回。还原思路是找到状态变量state的赋值位置,把每个case里的语句块按状态流转顺序拼接成线性代码。
具体实现要点:
- 定位
while(true) { switch(state) { ... } }结构; - 提取每一个
case的基本块; - 根据基本块末尾对
state的赋值构建控制流图; - 按控制流图顺序重新排列所有语句块。
如果state的赋值出现在if/else分支里,说明存在条件跳转,这时候不能简单线性拼接,需要保留分支结构。好在大多数业务站点的混淆强度并没有到全平坦化的程度,重点还是字符串解码和常量替换。真正遇到全平坦化的时候,建议先保留原代码,配合动态调试看几个轮次,再决定要不要全量还原。
死代码清理放最后做。经过前面的替换,很多变量变成了“定义了但没用到”,很多if分支成了恒真或恒假条件。跑一遍作用域分析,删除未被引用的声明和不可达分支,能进一步缩短代码体量。
4. 算法指纹识别:标准MD5还是魔改版?
4.1 从初始向量和K表一眼认出MD5
反混淆完成之后,代码已经可读,接下来要回答一个关键问题:md5__1038里的算法,到底是标准MD5、魔改MD5,还是只是名字里带md5、实际另有乾坤?
标准MD5的指纹非常明显:
- 初始向量固定为
A = 0x67452301, B = 0xefcdab89, C = 0x98badcfe, D = 0x10325476; - 64轮常数表的第一项是
0xd76aa478; - 每一轮用到的左移位数为
7, 12, 17, 22等固定值。
在还原后的代码里搜索0x67452301或67452301,基本能一锤定音。如果再看到0xd76aa478和0x98badcfe,那几乎可以肯定是MD5家族算法。
我用一个简单的Python脚本做指纹扫描:
code = open("bundle.deob.js", encoding="utf-8").read() fingerprints = { "IV_A": "0x67452301", "IV_B": "0xefcdab89", "IV_C": "0x98badcfe", "IV_D": "0x10325476", "K_TABLE_1": "0xd76aa478", "K_TABLE_2": "0xe8c7b756", } for name, value in fingerprints.items(): print(name, "=>", value in code.lower())四个初始向量加K表同时命中,基本可以实锤是标准MD5的实现。不过我通常会再检查一件事:函数里有没有对初始向量或常数表做修改。只要有修改,就不能直接套标准库,得把魔改点找出来。
4.2 加盐、大小写与多次哈希的判断
确认是MD5之后,还不能高兴太早。业务里很少直接md5(password),最常见的做法是加盐。
在还原后的业务代码里,找到调用md5__1038的地方,看它的入参是怎么拼出来的。我见过几种典型包装:
| 包装形式 | 识别特征 |
|---|---|
| 简单加盐 | 代码里有password + salt或salt + password |
| 动态盐 | 盐来自接口返回值、时间戳或随机数 |
| 结果转大写 | 调用后跟.toUpperCase()或输出统一大写 |
| 多次哈希 | for循环包裹hash调用,或链式调用两次md5 |
| 拼接多个字段 | uid + timestamp + token + password |
盐值一般有两种存放方式:固定字符串直接写在函数里,动态字符串通过参数传进来。判断动态盐时,需要回到上一步的调用栈,看调用点传给md5__1038的参数到底是什么。
我这次的案例里,还原后的代码大概是:
function md5__1038(input) { var raw = input.uid + input.timestamp + fixedSalt; return hex_md5(raw); }fixedSalt是一个固定的常量字符串,就定义在模块顶部。这一步明确了算法本身是标准MD5,但业务封装加了一层盐拼接规则。
4.3 怎么确认参数拼接顺序
参数拼接顺序比想象中更容易搞错。同样一段md5(uid + timestamp + salt),如果把uid和salt调换位置,结果就完全不同。几个实用方法:
- 在断点处打印
arguments,看调用点传入了哪些参数; - 在还原后的函数里加日志,打印
raw的最终内容; - 抓一组页面真实请求,把参与拼接的字段值都记下来,本地用不同顺序试算,跟请求里的
sign比对。
黑盒验证的效率很高。取一个真实的请求样本,把uid、timestamp、salt分别记成a、b、c,然后本地穷举几种常见顺序:a+b+c、b+a+c、a+c+b等,看哪个输出跟请求里的sign一致。这一步能快速排除大部分猜测。
如果怎么拼都不对,还有一个容易被忽略的点:拼接前可能存在额外编码,比如先做了一次Base64、URL编码或十六进制转换。这种情况在还原后的代码里能直接看到,搜索encodeURIComponent、Base64、toString(16)就能发现。
5. Python侧复现与签名验证:跑通一个闭环
5.1 用抓到的样本做黑盒验证
在浏览器Console里调用一次加密函数,保存输入输出。比如我抓到:
- 输入:
md5__1038("abc", "2024-01-01 12:00:00") - 输出:
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
然后把这个样本拿到本地,用对应规则算一遍。如果算出的结果完全一致,说明算法还原正确;如果不一致,先检查编码和大小写。
一个简单的Python验证脚本:
import hashlib def calc_sign(password: str, timestamp: str, salt: str) -> str: raw = password + timestamp + salt return hashlib.md5(raw.encode("utf-8")).hexdigest() sample = { "password": "abc", "timestamp": "2024-01-01 12:00:00", "salt": "FixedSalt", "expected": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", } result = calc_sign(sample["password"], sample["timestamp"], sample["salt"]) print(result == sample["expected"])如果输出匹配,这一步就闭环了。
5.2 从黑盒到白盒:本地复现脚本的写法
如果只是标准MD5加盐,直接用hashlib就够了。但如果遇到魔改版,事情会麻烦一些。魔改版的典型特征是初始向量或者常数表被改过,这时候标准库里怎么算都对不上。
我处理魔改MD5时,通常先把还原出的JS函数单独抽成一个文件,在Node里加载,用来跑测试用例。这样可以先保住一条可用的计算路径,再慢慢翻译成Python。
翻译到Python时,最需要注意的是循环左移运算。JS里的位运算结果也是32位整数,但写法不同。标准MD5核心一句:
def left_rotate(x, c): return ((x << c) | (x >> (32 - c))) & 0xFFFFFFFF每轮更新逻辑要完全对齐JS源码,尤其注意每轮F、G、H、I函数使用的位置和顺序。翻译完之后,用同一组测试样本做回归,确保每一步中间结果一致。
如果实在不想翻译,可以直接在Node里把JS函数当本地模块用。但工程上我更推荐最终翻译成Python,因为后续做数据分析、批量验证时Python链路更顺手。
5.3 验证时的几个细节(编码、字节序、空值)
几个看起来不起眼、实际非常容易翻车的细节:
- 编码:如果密码里有中文,要注意前端是UTF-8编码。Python用
str.encode("utf-8"),Node用Buffer.from(str, "utf8"),两侧必须一致。 - 字节序:标准MD5输出是小端字节序,正常
hexdigest()直接可用。但魔改版可能对每四个字节做了大小端交换,输出会整体变得不一样。 - 空值:拼接字段为空时,是直接省略还是保留空字符串?这会影响最终结果。我建议在还原代码里找一下有没有对空值做判断的
if。 - 时间戳单位:是秒还是毫秒。很多签名不一致的问题最终都是这里出错,毫秒和秒差了三位数,整个字符串长度就变了。
- Base64转义:拼接内容如果包含URL特殊字符,前端可能先做了编码再拼进去。
验证通过之后,建议把样本集保存成一份单独的文件,包括输入字段、期望输出、实际输出、编码方式、拼接规则。这样下次接口协议变更时,可以直接跑回归,不用重新挖一遍。
6. 反混淆过程中容易踩的坑与工程化建议
6.1 常见坑位
反混淆这个活,踩坑是常态。我把这几年比较典型的坑整理在这里:
- 坑一:直接格式化整个bundle,导致浏览器卡死或者文件巨大。正确做法是先把目标JS保存到本地,用CLI工具格式化,或者只格式化摘录出的代码片段。
- 坑二:Babel解析报错。压缩代码里可能使用了新语法或者特殊插件,解析时要选对
sourceType,必要时显式声明plugins,比如optionalChaining、nullishCoalescingOperator。还解析不了可以尝试sourceType: "unambiguous"。 - 坑三:把标准算法库代码当成业务代码。站点经常打包整个CryptoJS库,反混淆后会看到一大坨
md5、sha1、aes的实现,真正的业务调用点只有一行。识别办法是看调用点附近有没有业务参数字段,而不是看函数体有多长。 - 坑四:字符串还原后出现乱码。一种可能是字符串数组被多解开了一层,另一种是解码函数返回的是Unicode转义序列,还没有做二次解码。先检查
"\uXXXX"是否都被还原过。 - 坑五:AST替换改变了语义。比如把
identifier直接替换成字符串字面量,但上下文期望的其实是一个变量引用;或者替换时没有考虑逗号表达式、逻辑表达式等复杂父节点。建议每次做批量替换前,先跑一组样本回归,发现输出异常及时回退。
6.2 把反混淆沉淀成自动化流程
反混淆不是一次性工作。只要站点前端版本更新,之前整理的函数名、模块ID、盐值都可能变化。所以我建议把整个流程脚本化:
- 第一阶段:下载最新bundle,保存版本号和文件哈希;
- 第二阶段:用Babel解析,做字符串解码、常量折叠、简单平坦化;
- 第三阶段:生成反混淆后的可读JS;
- 第四阶段:写出算法指纹检测日志,报告关键常量位置;
- 第五阶段:跑样本回归,对比新旧版本的参数拼接规则是否变化。
每次版本更新后,直接跑一遍pipeline,然后用diff工具对比上一版和这一版反混淆结果,改动点一目了然。这个习惯能帮你省掉大量重复分析时间。
最后说一个真实体会:AST反混淆的工具链其实不复杂,真正花时间的是理解业务数据流。函数名、断点、调用栈、样本数据,这些才是把算法从“知道代码在算”推进到“理解为什么这么算”的关键。碰到混淆强一点的站点,多留几组黑盒样本,多打几个日志点,比盲目追求“全自动反混淆”要可靠得多。