处理混淆代码时,我最常听到的一句话是“先去控制流”,好像控制流平坦化是逆向的第一道门槛。实际操过几个样本就会明白,这道门前面还堆着一摞“数学题”。分发器里的 state 变量很少是干净的数字,它往往长这样:state = ((0x1F + 0x3) ^ 0x9) - 0x12;。如果插件不先把这串运算符折叠成字面量,后面还原分支的过程就会撞上一堆“动态索引”,每一步都像在雷区里探路。
这篇文章想讲的,就是 AST 反混淆插件里“去控制流前对运算符的简化操作”这个容易被跳过、却非常关键的环节。适合正在写反混淆脚本的人,也适合想理解混淆器原理的前端工程师或安全开发。核心思路是:不是所有简化都需要复杂的符号执行,很多运算符折叠用 AST 节点自带的类型信息就能安全完成,而且必须放在控制流还原之前。
1. 为什么“去控制流”要先做“运算符简化”
1.1 控制流分发器把索引伪装成一道算术题
控制流平坦化的本质,是把原本顺序执行的基本块打散,再通过一个 switch (state) 分发器来选择下一个要执行的块。混淆器不会傻到把 state 直接写成 case 1、case 2 能匹配的常量,而是通过异或、位移、加减法、甚至数组索引计算把它“算出来”。
比如一个典型片段:
js复制var state = (0x3F & 0x1D) + 14;
switch (state) {
case 26:
// 真实块A
break;
case 47:
// 真实块B
break;
}
这个例子里 (0x3F & 0x1D) + 14 的结果是 43,switch 里根本没有 case 43,说明真实的分发索引还要再经过一层变换。如果反混淆工具不做运算符简化,分发器的还原就无从下手,只能对 state 做符号执行或者约束求解。符号执行在几十个基本块的样本里还能跑,一旦块数量上百,路径爆炸直接教你做人。
所以我一直把运算符简化当成去控制流的前置关卡。目的很直白:把分发器里那些“动态计算出来的索引”先变成字面量,让后续步骤能基于常量做分支匹配。这比一上来就硬啃控制流平坦化要稳得多。
1.2 简化不是消消乐:三条边界必须守住
运算符简化听起来就是把 1 + 2 改成 3,但落地到 AST 插件里,“语义等价”是唯一不可逾越的红线。我自己整理的边界有三条:
- 求值顺序不能乱。JavaScript 表达式的求值顺序是从左到右,而且隐式类型转换可能触发
valueOf、toString这些方法。哪怕代码里写的是obj + 1,obj 也可能是一个带副作用的 getter,不能因为“看着像纯计算”就贸然折叠。 - 副作用必须保留。赋值表达式、函数调用、属性访问、
delete操作都可能有副作用。b = 1这种赋值在 if 条件里看起来像“恒真”,但它不是比较,不能化简成 true。 - 运算符优先级不能被破坏。AST 里的二叉树天然表达了优先级关系,但当我们手动调整节点、删除冗余括号时,很容易把
a + b * c处理成(a + b) * c。后面我会专门讲这个坑。
守住这三条,简化器才敢规模化地处理代码,而不是每次改完都要肉眼审查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 运算符简化要处理的 AST 节点族谱
2.1 BinaryExpression:加号的一体两面
二元表达式是简化里量最大的一类,但也是翻车率最高的。最大的坑就是加号:JavaScript 的 + 身兼两职,数值相加和字符串拼接。折叠时必须先推断类型,推断不出来就保守处理。
我总结的判断规则是:
- 如果左右两边都能确定为数字字面量,做算术折叠。
- 如果任一边能确定为字符串字面量,做字符串拼接折叠。
- 如果类型不明确,不折叠。
举个例子,"a" + 1 + 2 从左到右求值是 "a12",而 1 + 2 + "a" 是 "3a"。AST 本身是二叉树,已经隐含了结合顺序,所以按 path 自底向上折叠是安全的。真正危险的是某些简化器会“跳过层级”把相邻的节点拉平,这时候左结合就会变成右结合。
下面是我在插件里用的一个简化函数,重点在于先去判断节点是否“纯”:
js复制function isPureNode(node) {
if (!node) return false;
if (node.type === "NumericLiteral" || node.type === "StringLiteral" || node.type === "BooleanLiteral") {
return true;
}
if (node.type === "UnaryExpression") {
return node.operator !== "delete" && isPureNode(node.argument);
}
if (node.type === "BinaryExpression" || node.type === "LogicalExpression") {
return isPureNode(node.left) && isPureNode(node.right);
}
if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
return true;
}
return false;
}
isPureNode 是我所有折叠操作的前置判断。一个节点不是纯节点,后面所有的折叠逻辑直接跳过,不做任何冒险的事。
位运算和比较运算相对简单,因为它们的目标就是数值或布尔值。比如 (1 << 4) ^ 0x0A 这种,只要能确认操作数是数字字面量,直接算成 6 就行。比较运算 5 > 3 折叠成 true 也没问题。
2.2 UnaryExpression:一元运算里的几个“钉子户”
一元运算包括 !、+、-、~、typeof、void、delete。其中前四个可以做安全的字面量折叠,后三个要格外小心。
!true 折叠成 false 很简单。~5 根据
