压缩 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 作用域级重命名)

  1. 键控:声明行:字母 → 语义名 的映射表(RENAME[lineOf(binding.identifier) + ':' + letter] = name)
  2. 只改本作用域声明的 binding:遍历所有 scope,对每个单字母 binding 用 scope.rename(old, new)。绝不全局字母替换——单字母严重 shadowed,r/h 等会误改外层 Three.js 模块别名
  3. 同行冲突处理:line:letter 只在同一行声明了同名不同作用的变量时才撞键(如 let t = treePresets.map((t, a) => ...) 的 var 和回调参数,或 .then(e => loader.load(e, e => {...})) 的外层/内层回调)。解法:depth-sort 让更深作用域优先认领,输给更深作用域的外层 binding 用精确结构谓词(Pass B)兜底
  4. 两道防护:

    • 同作用域重名守卫:目标名已存在于本作用域 → 跳过报错(抓到了 resize() 里 h/c 都命名成 targetWidth 的重复声明)
    • 遮蔽检测:目标名存在于外层 → 记日志人工复核(抓到了 arrivalWingFold = arrivalWingFold 自赋值真 bug——局部遮蔽外层后,块内对外层的赋值变成自赋值)
  5. 结尾全量扫描:遍历所有作用域,任何残留单字母 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),就不会漏。

验证链(按强度排序,缺一不可)

  1. node --check —— 语法/重复声明
  2. TDZ 校验 —— 见下方"最大的坑"。找出同块内 let/const 在声明之前被引用的情况
  3. AST 结构等价:把原始与重命名版的 AST 归一化(忽略标识符 name、位置、注释、shorthand),sha256 比较 equal=true —— 证明没改任何程序结构
  4. 数值回归:对可运行的核心纯函数(如树生成器)做多组 seed 逐项相等测试
  5. 浏览器渲染像素对比:同一页面换两版引擎,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