处理混淆代码时,我最常听到的一句话是“先去控制流”,好像控制流平坦化是逆向的第一道门槛。实际操过几个样本就会明白,这道门前面还堆着一摞“数学题”。分发器里的 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 根据按位取反规则折叠成 -6 也不难。+ "123" 折叠成数字 123 也常见。真正麻烦的是双重取反 !!x。如果 x 能确定为布尔值,!!x 可以简化成 x;如果不能确定,替换成 Boolean(x) 在大多数情况下等义,但要考虑 Boolean 是否被脚本重写过。虽然这种情况少见,逆向样本里还真的遇见过,所以我的插件里默认不主动生成 Boolean(x),保持原样。
void 0 在混淆代码里很常见,其实就是 undefined,可以直接替换成 undefined 字面量。typeof 的折叠有一个隐蔽问题:typeof undeclaredVar 不会抛错,返回 "undefined",但如果插件试图把它和其他表达式一起计算,很容易忽略作用域因素。所以我只对 typeof 后跟字面量的情况折叠,跟标识符的一律不动。delete 则完全不碰,它有返回值,还有可能触发严格模式下的语法错误。
2.3 模板字符串、逻辑表达式与条件表达式的化简
模板字符串在混淆器里经常被用来做字符串拼接。如果一个 TemplateLiteral 没有任何 expressions,比如 `hello`,直接替换成 StringLiteral 是安全的。如果只有一个表达式且前后没有字符串裸文本,比如 `${a}`,它其实等价于 String(a),这个转换会对类型有影响,我通常保守不做。
逻辑表达式和条件表达式的化简收益大,但也最需要边界感:
true && a可以简化成a,false || a也可以简化成a。false && a简化成false要谨慎,因为 a 根本没有执行,不会抛出错误,但如果写成false再被后面的代码当函数调用,语义就变了。实际上从表达式结果来说是对的,但从程序行为来说,a未求值这件事本身不会有副作用,所以这个化简可行。a || true不能简化成 true,因为当 a 为真值时,结果其实是a而不是 true。condition ? A : A这种两个分支完全相同的情况,可以直接替换成 A,但要注意测试表达式是否有副作用,没有副作用才安全。condition ? true : false可以简化成Boolean(condition),不过同样要评估副作用与类型转换。
条件表达式在控制流分发器里出现的频率非常高,因为有些混淆器会把 if (state === 1) blockA else blockB 改写成条件表达式嵌套。先简化这些节点,后面控制流还原才能按真实分支重组。
3. 简化操作如何做成插件模块
3.1 插件接口与配置项设计
真正做插件的时候,我建议不要把逻辑写死在一个脚本里。一个可复用的简化模块,至少要暴露一个 plugin 接口,并且允许调用方通过配置项开关某类简化。
我这边设计的配置项大致如下:
| 配置项 | 类型 | 默认值 | 作用 |
|---|---|---|---|
| conservative | boolean | true | 保守模式,关闭不确定类型折叠 |
| foldUnary | boolean | true | 是否折叠一元运算 |
| foldBinary | boolean | true | 是否折叠二元运算 |
| foldTemplateLiteral | boolean | true | 是否折叠模板字符串 |
| foldLogical | boolean | true | 是否折叠逻辑/条件表达式 |
| maxIterations | number | 10 | 重复遍历的最大次数 |
conservative 是很有用的开关。因为有些样本里面藏着环境检测、反调试代码,如果激进地把所有数字运算都折叠成常量,反而可能触发代码里的自校验逻辑,让还原后的代码在真实环境里跑不起来。默认开保守模式,只在明确要做“彻底静态分析”时才关掉。
3.2 一个可运行的核心遍历骨架
下面这段代码是基于 @babel/traverse 写的简化插件骨架,保留了最核心的遍历和重复迭代逻辑:
js复制const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
function createSimplifierPlugin(options = {}) {
const config = {
foldUnary: true,
foldBinary: true,
foldTemplateLiteral: true,
foldLogical: true,
conservative: true,
maxIterations: 10,
...options,
};
function foldExpression(path, state) {
if (isPureNode(path.node)) {
const result = tryFold(path.node);
if (result) {
path.replaceWith(t.valueToNode(result.value));
state.changed = true;
}
}
}
return {
visitor: {
BinaryExpression: {
exit(path, state) {
if (config.foldBinary && !config.conservative) foldExpression(path, state);
},
},
UnaryExpression: {
exit(path, state) {
if (config.foldUnary) foldExpression(path, state);
},
},
TemplateLiteral: {
exit(path, state) {
if (config.foldTemplateLiteral && path.node.expressions.length === 0) {
path.replaceWith(t.stringLiteral(path.node.quasis[0].value.cooked));
state.changed = true;
}
},
},
LogicalExpression: {
exit(path, state) {
if (!config.foldLogical) return;
const { left, right, operator } = path.node;
if (!isPureNode(left) && hasSideEffects(left)) return;
if (left.type === "BooleanLiteral") {
const keep = operator === "&&" ? left.value : !left.value;
path.replaceWith(keep ? right : left);
state.changed = true;
}
},
},
ConditionalExpression: {
exit(path, state) {
if (!config.foldLogical) return;
const { test, consequent, alternate } = path.node;
if (test.type === "NumericLiteral" || test.type === "StringLiteral" || test.type === "BooleanLiteral") {
const truthy = test.type === "NumericLiteral" ? test.value !== 0
: test.type === "StringLiteral" ? test.value.length > 0
: test.value;
path.replaceWith(truthy ? consequent : alternate);
state.changed = true;
}
},
},
},
};
}
function tryFold(node) {
if (node.type === "BinaryExpression") {
const left = node.left, right = node.right;
if (left.type === "NumericLiteral" && right.type === "NumericLiteral") {
switch (node.operator) {
case "+": return { value: left.value + right.value };
case "-": return { value: left.value - right.value };
case "*": return { value: left.value * right.value };
case "/": return { value: left.value / right.value };
case "^": return { value: left.value ^ right.value };
case "&": return { value: left.value & right.value };
case "|": return { value: left.value | right.value };
case "<<": return { value: left.value << right.value };
case ">>": return { value: left.value >> right.value };
case ">>>": return { value: left.value >>> right.value };
}
}
}
return null;
}
function run(ast, options) {
const plugin = createSimplifierPlugin(options);
const state = { changed: true };
let iterations = 0;
while (state.changed && iterations < plugin.config.maxIterations) {
state.changed = false;
const visitor = typeof plugin.visitor === "object" ? plugin.visitor : {};
traverse(ast, {
...visitor,
Program: {
enter(path) {
state.path = path;
},
},
}, undefined, state);
iterations++;
}
}
这里有两个小技巧值得说。一个是 exit 节点阶段做折叠,因为子节点先处理完,当前节点才有机会拿到已经更新后的子节点。另一个是循环遍历,很多新手只跑一次 traverse,结果折叠出来的新节点没有被再次处理。比如 1 + 2 + 3,第一次遍历先把 1 + 2 变成 3,如果不继续遍历,3 + 3 就不会被折叠成 6。
3.3 为什么选 AST 层,而不是源码字符串层
有人可能会问,直接用正则匹配 1 + 2 替换成 3 不是更快吗?如果只处理玩具级别的示例,确实可以。但一遇到真实混淆样本,正则方案直接崩溃:
- 正则会匹配到字符串内容。比如
var s = "1 + 2",如果把字符串里的内容替换了,程序行为直接改变。 - 正则无法感知作用域。
x = 1 + 2和"x = 1 + 2"肉眼可辨,但面对模板字符串、注释、正则字面量时,正则简直是在走钢丝。 - 正则不知道优先级。
a * (b + c)里的b + c可以替换,但替换之后如果整个表达式被拼回字符串,你还要手动加括号。AST 层则完全不需要手动管括号,@babel/generator会根据节点结构自动生成正确的括号。
我在开发插件时,大量的时间其实不是花在“怎么折叠”上,而是花在“怎么确定能折叠”。AST 层提供了作用域、类型信息、父子关系这些上下文,让“确定能折叠”这件事变得可能。正则没这个能力,所以它只能做玩具。
4. 简化器最容易翻车的边界情况
4.1 把赋值表达式当成比较运算
这是我在早期版本里真实踩过的坑。当时处理 if (a = 1) { ... },我的简化器看到 a = 1,觉得这是一个常量条件(因为 1 是真值),直接改成 if (true) { ... }。结果赋值语句没了,后面所有依赖 a 的代码全部错乱。
赋值表达式和比较运算在源码里很容易区分,但要小心某些混淆器把 a == 1 改写成 (a = 1) == true 之类的结构。前者是赋值,后者才是比较。我的处理方式是在简化器里维护一个“黑名单节点类型”:AssignmentExpression、UpdateExpression、CallExpression、NewExpression、TaggedTemplateExpression,这些节点一律不作为可折叠的纯节点,即使它们的子节点看起来是纯字面量。
顺带说一句,while (i = next()) 这种结构在真实代码里是合法的,而且是有意为之。我后来测试用例里专门加了一条,防止回归。
4.2 加法折叠时的左结合问题
前面提过 "a" + 1 + 2 和 1 + 2 + "a" 的结果不同。这里还有更隐蔽的问题:字符串和数字混合拼接时,隐式类型转换的触发点是“从左往右”逐步发生的。看这个例子:
js复制let a = 1 + 2 + "px"; // "3px"
let b = "width:" + 1 + 2; // "width:12"
如果简化器在遍历 AST 时,先看到了内部的 1 + 2 并折叠成 3,那没问题。但如果插件刻意把左右子树“交换顺序”或者“拉平”成一个数组再按某种偏好拼回去,就会翻车。AST 二叉树本身已经保证了结合顺序,所以关键原则是:永远只自底向上折叠,不做跨层级的重组。
4.3 副作用与 getter 的隐性消耗
JavaScript 的属性访问 obj.prop 可能触发 getter,数组访问 arr[0] 也可能触发 Proxy 拦截。再加上函数调用可能修改闭包变量、eval 可能影响整个作用域,所以折叠操作必须非常保守。
我在插件里维护了一个 hasSideEffects(node) 函数,遇到这些类型直接返回 true:
CallExpression、NewExpression、TaggedTemplateExpressionAssignmentExpression、UpdateExpression、DeleteExpressionMemberExpression(在保守模式下全算有副作用,激进模式仅当对象为纯字面量时才允许)AwaitExpression、YieldExpression、ImportExpression
有副作用就绝不折叠,这是底线。如果你为了多简化几行代码而把一个 Math.random() 当成纯节点处理,那后续的代码还原基本就是做无用功。
4.4 标识符遮蔽与常量传播的边界
运算符简化通常会配合常量传播使用,比如 let x = 1; return x + 2;,如果能确定 x 恒为 1,就可以把 x 替换成 1 再折叠。但这里有个大坑:作用域遮蔽。
看这个例子:
js复制function test() {
let x = 1;
return function () {
let x = 2;
return x + 1;
};
}
如果把外层的 x 的常量值传播到内层,就会把内层应该等于 3 的表达式错误地折叠成 2。所以做任何标识符替换前,必须通过 path.scope.getBinding("x") 检查标识符解析到的是哪个 binding,而且这个 binding 必须是当前节点的祖先作用域,或者是同一作用域。
这里需要强调一个点:简化模块在“去控制流”阶段运行时,通常面对的是一个大的函数体,里面可能嵌套了多层子函数和块级作用域。如果插件本身不做作用域感知,宁可少折叠,也不要折叠错。错了之后 debug 的成本远高于多跑几轮遍历的时间。
4.5 反复遍历的收敛问题
AST 折叠是一个迭代过程,因为一次折叠会产生新的可折叠机会。我的插件用一个 while 循环控制遍历次数,state.changed 作为是否继续的信号。但这里必须设置 maxIterations,防止某些极端样本里两个节点互相折叠导致死循环。
我曾经遇到过一个样本,a = b + 1; b = a - 1; 这种互相引用关系,如果简化器配合常量传播,会陷入 A 替换 B、B 替换 A 的循环里。虽然最后在循环次数上能拦住,但调试时一度以为程序卡死了。所以我现在写这类插件,都会在一开始就加一个“变化日志”,打完每个 iteration 后打印到底折叠了哪些节点,方便快速定位可疑的循环依赖。
5. 测试与回归设计:简化器的安全网
5.1 从真实混淆样本里切 fixture
只写 1+2=3 这种理想用例没有任何意义。我从实际操作里总结出来的 fixture 来源有三种:
- 用公开的混淆器把一段自己的业务代码跑一遍,把输出结果作为样本。
- 从真实样本里手动切出“分发器片段”,重点关注那些 switch 前有一大串位运算的代码。
- 故意构造边界用例,比如
"a" + 1 + 2、a = 1在 if 里、obj.x + 1带 getter 场景。
每个 fixture 都有两个文件:输入文件和期望输出文件。这样设计的好处是,插件如果哪天改了逻辑,跑一次测试就知道哪些样本行为变了。
5.2 用拍快照和断言锁定行为
我用的测试框架是 jest,测试用例大概长这样:
js复制const { parse } = require("@babel/parser");
const generate = require("@babel/generator").default;
const { run } = require("../src/simplifier");
function transform(src) {
const ast = parse(src, { sourceType: "script" });
run(ast);
return generate(ast).code;
}
test("fold binary before ctrl flow", () => {
const input = `
var state = (0x3F & 0x1D) + 14;
switch (state) {
case 26: break;
case 43: break;
}
`;
expect(transform(input)).toMatchSnapshot();
});
test("does not drop assignment in if", () => {
const input = `if (a = 1) { b(); }`;
expect(transform(input)).toContain("a = 1");
});
快照测试的好处是防回归,坏处是如果代码结构大改,快照会大批量失效,需要人工确认。所以我通常把快照测试和断言测试混着用:断言测试针对关键语义(比如不丢赋值、不折叠有副作用的调用),快照测试针对整体结构输出。
5.3 我对“简化到什么程度”的取舍
每个写反混淆插件的人都会问自己:要激进的简化,还是保守的还原?
我个人经验是分场景。如果目标是让代码可读、可用于后续人工审计,保守模式更合适。因为很多混淆代码会故意使用“运行时环境取值参与计算”,比如 Date.now() % 2 === 0,这种表达式你永远无法静态折叠成常量,但它在分发器里出现并不代表分发是动态的,可能只是混淆器插入的干扰项。
如果激进地把所有能折叠的都折叠了,表面上代码干净了,但可能把一些本该在运行时才触发逻辑的边界暴露出来,导致后续静态分析产生误判。所以在插件里保留一个开关,让操作者按需选择,比一味追求折叠率更有工程价值。
6. 简化之外还能做点什么
6.1 从字面量折叠到常数传播
运算符简化主要处理的是“表达式内部的折叠”,常数传播则是把变量的常量值沿引用链带到使用点。比如:
js复制var a = 1;
var b = a + 2;
先做运算符简化,a 还是标识符,没法折叠。但如果先做常数传播,a 被字面量 1 替换,之后 b = 3 就迎刃而解。
我用的传播逻辑大致如下:
js复制const binding = path.scope.getBinding(name);
if (binding && binding.constant && binding.path.isVariableDeclarator()) {
const init = binding.path.node.init;
if (isPureNode(init)) {
path.replaceWith(t.cloneNode(init));
state.changed = true;
}
}
这里又回到作用域的问题:getBinding 返回的 binding 已经处理了作用域链,所以不会跨作用域误替换。这也是我推荐所有反混淆插件都基于 Babel 这类成熟 AST 工具的原因,作用域查询、path 失效管理、生成器括号处理这些底层问题都不用自己造轮子。
6.2 “简化 → 去控制流”的管线先后顺序
在多次实战之后,我沉淀出来的管线顺序是:
- 解析 + 格式化,去除多余分号和换行干扰。
- 括号规范化,让表达式的二叉树结构更规整。
- 运算符简化,把能折叠的数学计算全部变成字面量。
- 常数传播,把常量变量引用替换成字面量。
- 再来一轮运算符简化,把上一步传播出来的新机会消化掉。
- 控制流分发器还原,此时 state 大多是字面量,分支匹配一下子简单很多。
- 死代码删除,清理掉还原过程中暴露出来的不可达分支。
这个顺序不一定对所有样本都最优,但对大多数以“分发器 + 索引计算”为特征的混淆器适配性很好。我的经验是,跳过了第 3 步直接做第 6 步时,控制流还原的误判率会高很多;而加上第 3 步之后,很多原本像动态分支的代码会被自动折叠成静态分支,还原效果肉眼可见地提升。
说到底,AST 反混淆是一个环环相扣的流水线,前一个环节输出多干净,后一个环节结果就有多稳。运算符简化作为“去控制流”的前置操作,看起来不起眼,却是让后续所有还原步骤不被数字运算干扰的关键。希望这篇能把“先简化、再解流”这个思路讲清楚,给正在折腾 AST 插件的朋友省一点弯路。
