压缩 JS 变量语义还原 (Minified JS Semantic Renaming)
压缩 JS 变量语义还原:对 Turbopack/webpack 标准压缩(非 obfuscator 混淆)后的前端 bundle,把单字母变量名恢复成可读语义名的方法论。核心认知:压缩 ≠ 混淆——压缩只改变量名、保结构;字符串字面量(含 GLSL shader 里的英文设计注释)、作用域结构、AST 绑定关系全部完好,所以"变量语义还原"是可行的,且与反混淆(拆旋转器/字符串数组)完全是两回事。
适用场景与判断
- 识别特征:
(globalThis.TURBOPACK || ...).push([..., 222197, runtimeModule => {...}])这种模块包装、变量名全部是e/t/a/o/r单字母、但字符串和注释完好 - 先判断是压缩还是混淆:压缩直接可读(只是名字短),混淆有字符串数组/旋转器/
_0x[hex]指纹(见 JavaScript 混淆逆向与反混淆) - 没有 source map 时,还原出的名字是"语义恢复标签",不是作者原始命名——命名依据来自数据流、调用关系和上下文
核心机制(babel AST 作用域级重命名)
- 键控:
声明行:字母→ 语义名 的映射表(RENAME[lineOf(binding.identifier) + ':' + letter] = name) - 只改本作用域声明的 binding:遍历所有 scope,对每个单字母 binding 用
scope.rename(old, new)。绝不全局字母替换——单字母严重 shadowed,r/h等会误改外层 Three.js 模块别名 - 同行冲突处理:
line:letter只在同一行声明了同名不同作用的变量时才撞键(如let t = treePresets.map((t, a) => ...)的 var 和回调参数,或.then(e => loader.load(e, e => {...}))的外层/内层回调)。解法:depth-sort 让更深作用域优先认领,输给更深作用域的外层 binding 用精确结构谓词(Pass B)兜底 两道防护:
- 同作用域重名守卫:目标名已存在于本作用域 → 跳过报错(抓到了
resize()里h/c都命名成targetWidth的重复声明) - 遮蔽检测:目标名存在于外层 → 记日志人工复核(抓到了
arrivalWingFold = arrivalWingFold自赋值真 bug——局部遮蔽外层后,块内对外层的赋值变成自赋值)
- 同作用域重名守卫:目标名已存在于本作用域 → 跳过报错(抓到了
- 结尾全量扫描:遍历所有作用域,任何残留单字母 binding 都报出来
清单脚本的坑(漏网之鱼)
枚举单字母 binding 的清单脚本如果只处理 FunctionDeclaration/FunctionExpression/ArrowFunctionExpression 的 Identifier 参数 + Identifier 变量声明,会漏掉:
- ObjectMethod 参数(对象字面量方法
vert(e, t, a, o, r) {...}) - 解构参数/声明(
forEach(([e, t], a) => ...)、for (let [e, t, a, o] of ...)、let { perch: m } = ...) - CatchClause 参数(
catch (e) {}) - AssignmentPattern 默认参数(
function f(e, t = flapStateA)的t)
最终校验扫描直接遍历 scope.bindings(babel 会把上述全部算作 binding),就不会漏。
验证链(按强度排序,缺一不可)
node --check—— 语法/重复声明- TDZ 校验 —— 见下方"最大的坑"。找出同块内
let/const在声明之前被引用的情况 - AST 结构等价:把原始与重命名版的 AST 归一化(忽略标识符
name、位置、注释、shorthand),sha256 比较equal=true—— 证明没改任何程序结构 - 数值回归:对可运行的核心纯函数(如树生成器)做多组 seed 逐项相等测试
- 浏览器渲染像素对比:同一页面换两版引擎,headless 截图对比,平均像素差 ~2/255(仅动画帧时序差异,结构一致)即通过
最大的坑:AST 等价证明不了"改名安全"
把两个不同的绑定改成同一个名字,AST 结构完全不变(因为比较时忽略标识符名),
所以第 3 条对这类错误是完全盲的。实战中踩到的形态:
for (let [skeleton, meshList] of skeletonMap) { // 循环变量叫 skeleton
let bones = skeleton.bones.slice(), ... // ← 引用
let skeleton = new THREE.Skeleton(bones, ...); // ← 同块内后声明的 let,遮蔽 + 落入 TDZ
}运行时抛 Cannot access 'skeleton' before initialization,整条加载链中断。而这道错误
同时漏过了三道验证:AST 等价忽略名字所以看不见;数值回归不经过这条路径;渲染像素差里
树和天空占绝对主导,一小块区域失败推不动数字。
结论:AST 等价只证明"结构没变",不证明"语义没变"。同作用域重名、遮蔽、TDZ 这三类必须
靠专门的作用域感知检查(scope.bindings 同名检测 + TDZ 引用位置检测),或者最直接的——
把产物真的跑起来。静态分析再全,也替代不了一次真实运行。
相关
- JavaScript 混淆逆向与反混淆(混淆类型;两篇是逆向谱系的两端:压缩→语义还原,混淆→反混淆)
- 实战项目:Anthropic Fable landing 引擎(
fable-landing-reverse),脚本ast-tools/semantic-rename-round2.js,键控声明行:字母+ depth-sort + Pass B + 守卫 + 全量扫描 + TDZ 校验 - 下游应用:6767.chat 首页 hero 背景(Turbopack chunk → 最小 runtime shim → 独立 bundle,明暗主题映射到引擎 look)
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Web