JavaScript 混淆最佳实践

默认发布经过混淆的 JavaScript,用指令标记真正重要的区域,并持续轮换构建,让攻击者始终追不上。

发布 Web 应用时,混淆应当是默认选项

你发布的 JavaScript 中,一切都是可读的。密钥、业务逻辑、你所依赖的算法:任何人都能打开这个 bundle,前沿 LLM 也会在被要求时逐行解释它。安全扫描器会展示真实生产环境 bundle 中究竟暴露了什么。在你自己的站点上运行一次,看看你已经泄露了什么。

混淆这个 bundle 能换来具体、可核实的收益:

  • 针对你应用编写的补丁,撑不过下一次发布。 用来定制或自动化你应用的 userscript、Tampermonkey 脚本或扩展,都必须锚定在某些稳定的东西上:一个函数名、一段字面量字符串、一种可识别的结构。重命名和字符串编码会移除这些锚点,而新种子会在每次构建时重塑输出。作者针对某一个版本写好补丁,下一个版本就不再匹配,他们分发出去的用户只能守着一个失效的脚本,直到有人重写它。
  • 针对你代码的查找替换,找不到匹配对象。 简单的文本替换是自动化或篡改 Web 应用最便宜的方式,它能对 isSubscribed 和 "Upgrade to continue" 生效。但你发布的文件里,这两个字符串都不存在。
  • 你的常量不再是可读文本。 字面量会被编码,因此对某个密钥、某个接口地址、价格表或付费墙文案的 grep 搜索什么都找不到,大规模读取 bundle 的扫描器和爬虫同样一无所获。
  • 输出结果不是你的程序。 AfterPack 会把逻辑重写为等价但会破坏信息的形式。读者逐步分析这份产物,得到的是一个能运行的程序,而不是你写的那一个,那些让你的实现值得抄袭的算法选择已经不见了。
  • 成本会反复产生。 没有跨构建不变的结构可供瞄准,因此针对上一个版本写的反混淆器或补丁不会延续到这一个版本,别人已经投入的工作也无法迁移过来。

以上都不是保证,也不能替代服务端的权限控制。任何客户端代码都能在某种程度上被还原,因为运行时终究要执行它。真正改变的是代价和节奏:任何想读懂或修改你应用的人,都要在你发布的每个版本上重新付出代价。这个页面接下来要讲的,是如何把这份保护用在真正重要的地方。

持续轮换构建,领先攻击者一步

单个已混淆的版本是一个固定目标,只要投入足够的精力,验证码破解工具、作弊扩展或爬虫都能适应它。默认情况下每次构建都会使用新种子,因此每个版本在结构上都已经和上一个不同,已经适应了某一版本的人,到了下一版本又得从头开始。

如果你发布得不频繁,这层防护在版本之间就会失去效果。可以增加一个与代码改动无关的定时重建,让已部署的 bundle 持续轮换;构建与 CI 介绍了如何把它接入流水线。

先选预设,再标记重要区域

默认预设是 light(复杂度目标为 5):它会编码字符串字面量、重命名标识符并重写语法,不添加结构层。如果生产构建需要真正的保护,可以全局调高它:

$ npx afterpack@latest dist/ --preset=hard

这个梯度是 minify(0)、light(5)、medium(7)、hard(12)、extreme(25);详见预设。--complexity=<number> 会覆盖预设携带的目标值:--preset=hard --complexity=40 就是目标值为 40 的 hard。以这种方式设置的值会作用于每一个词元,即使在 light 上也是如此。

然后标记出一旦出错代价最高的区域,让更重的处理落在真正值得付出体积代价的地方。

用指令标记一个区域

/* @afterpack preset=hard */
function checkLicense(token: string): boolean {
  return verifySignature(token, PUBLIC_KEY);
}
/* @afterpack end */

const apiKey = /* @afterpack preset=extreme */ "YOUR_SECRET";
// only the rest of this line is covered

指令是完整参考:语法、指令可以设置的每一个键、该标记什么以及为什么,以及当构建无法应用某条指令时会发生什么。区域级覆盖只在 Pro 构建中生效。

两种反篡改变换

反篡改不属于 preset=hard 的一部分。它是两个显式的 Pro 配置开关,默认都是关闭的,且不会应用在 Free 上:

  • transforms.comparisonHardening.enabled 会把针对构建期字符串字面量的严格相等判断(x === "LIT")重写成单向摘要校验,让该字面量完全不出现在产物中。任何你不想被 grep 出来的常量比较,都可以用它。
  • transforms.selfIntegrity.enabled 会在已混淆的输出上添加一层完整性校验。未被篡改时,它对行为没有任何影响;文件一旦被篡改,就无法正确解码。它需要附着在 medium 或更高级别的代码上,因此 light 构建只在被提高到这一级别的区域内带有它。从引擎 0.2.3 起,没有这类代码的构建会以 DIAG_SELF_INTEGRITY_INERT 失败,而不是在没有校验的情况下交付。

为整个程序开启这两项。详见配置。

保护绑定域名的许可证或试用

AfterPack 没有内置的域名锁定或到期变换。你需要自己写出检查逻辑,AfterPack 会保护你写的这段 JavaScript。下面是一个带试用到期时间的、按域名生效的许可证检查:

/* @afterpack preset=hard */
if (location.hostname !== "customer-domain.example.com") return;
if (Date.now() > TRIAL_EXPIRES_AT) return;
/* @afterpack end */

打开 transforms.comparisonHardening.enabled 后,location.hostname 的比较会变成摘要校验,允许的域名不会出现在产物中,对域名的 grep 搜索什么都找不到。preset=hard 会让周围的逻辑处在复杂度目标 12、并开启常量下限的状态,新种子又会在每个版本重塑它。

这会提高绕过检查的成本和频率要求,但不会把检查从攻击面上移除。一个有决心、能运行代码的攻击者仍然能找到并修改这处比较,只是每个版本都要重新做一遍。把它当作提高试用门槛的手段,任何关系到真实收入的检查,都应该再配上一次服务端的权限校验。

不要把保护浪费在第三方代码上

像 React、Lodash 或某个 CSS-in-JS 运行时这样的第三方 bundle,值得投入的保护很少。它们本来就是公开的,还会让你的 bundle 体积膨胀,并且往往使用需要额外确认的运行时反射。

`paths.exclude` 是文件级别的豁免

paths.exclude 会在解析之前完全跳过匹配的文件,原样按字节返回。被排除的文件会被报告为 DIAG_PATH_EXCLUDED。它只在文件级别生效:当某个库构建把你的代码和一个依赖拼接进同一个输出文件时,就没有单独的文件可以排除了。这种情况下改用 identifiers.reserved。

有四种方式可以控制 AfterPack 能看到什么:

  • 排除文件,用 paths.exclude,例如 ["**/*.min.js", "dist/vendor/**"]。* 和 ? 在一个路径片段内匹配,整段 ** 可以匹配零个或多个片段,匹配区分大小写。
  • 把 CLI 指向你想保护的目录。 它会递归遍历这个构建目录,处理找到的每一个 .js/.mjs/.cjs 文件,跳过嵌套的 node_modules/(可以用 paths.include 把它纳入)以及它自己生成的 .backup.<hash> 副本。这个目录之外的第三方 chunk 完全不会被触及。
  • 在打包工具里把第三方代码拆分成独立的 chunk,然后让 AfterPack 处理一个不包含它的目录。如果你愿意承担这个代价,第三方 chunk 本身照样可以正常混淆。
  • 用 skip 标记区域,用于某个被打包进同一文件的依赖代码,且需要保持可识别的情况。

你的 import 依然能正常解析。

AfterPack 完整支持 ES2022 私有类字段和方法:#field 和 #method() 能被正确解析,并和其他一切一样被重命名和保护。

保留必须保持可读的部分

有些代码必须保留原始名称,运行时才能正常工作。框架预设覆盖了常见情形。除此之外,应尽量小范围地做保留,永远不要全局关闭重命名,否则你会在整个 bundle 上丢掉成本最低的一层保护。

场景该怎么做
调用方按名称导入的公开导出在导出周围使用 /* @afterpack skip */ … /* @afterpack end */,或者用 identifiers.reserved 固定名称
你实际需要调试的堆栈跟踪用你保留在部署目录之外的 source map 做符号还原。identifiers.rename=false 会让整个 bundle 中的每个标识符保持原样
基于反射的依赖注入(Angular、NestJS、Inversify)在配置中用 reflection.allow 确认某种自定义模式;未被确认的模式会导致构建失败
instanceof / 按类名做分支用 identifiers.reserved 列出这些类名,或在声明周围用 /* @afterpack skip */
第三方 API 符号被打包进和你自己代码相同的文件(例如某个内联依赖的 Rollup/Vite 库构建)用 identifiers.reserved,整个构建用一个普通名称,或者用 { glob, names } 条目匹配特定文件
没有其他代码调用的脚本identifiers.globals.rename: true。选择让引擎原本会保留的全局作用域也被重命名。默认关闭,只有在没有任何外部代码读取这些名称时才安全

identifiers.reserved 只保留你列出的名称,无论它们出现在哪里,匹配的是重命名发生之前、源码里编写的名称:精确匹配、整词匹配、区分大小写。Free 版本即可使用。当你没法标记整个区域——因为你的代码和某个第三方依赖共享同一个文件——时,就用它。

如果只需要在一处保留名称,用 identifiers.reserved 或 /* @afterpack skip */;详见你可以设置什么。

每一处保留都会降低那些代码行上的保护强度。只在运行时确实需要的地方使用它。

种子策略

种子决定了已混淆输出的形态。默认情况下每次构建都会使用一个随机种子。在生产环境中应保持这个默认值:每个版本都会变化的构建,是最主要的防线。

只在一个地方固定种子:CI,当你需要可复现的输出来命中缓存或做基准快照测试时。

$ npx afterpack@latest dist/ --seed=git

--seed=git 会从 git rev-parse HEAD 派生出种子。如果没有仓库,它会回退到一个新的随机种子并打印提示,而不是让构建失败。这样输出在每次提交下都是确定的,同时每个版本仍会轮换。

种子也有对应的环境变量 AFTERPACK_seed,取值和 --seed 相同。多 bundle 构建会通过这个变量自动共享同一个种子,无论是在同一个进程内还是跨进程。只有在需要复现某个特定构建时才手动设置它;显式传入的 --seed 或 seed 选项仍然优先于它。

生产构建只做一次,让同一份字节内容依次晋级 qa → preprod → prod。开发构建不做混淆,因此日常开发不受影响。

不要给生产环境使用固定不变的种子。这会让输出在各个版本之间不再改变形态。

让这些产物远离部署目录

AfterPack 会在本地写出三类产物,每一类都比它所描述的 bundle 更敏感。

  • Protection Map(protectionMap.html)内嵌了你的原始源码。它总是被写入项目根目录下一个被 gitignore 排除的 .afterpack/ 目录,绝不会出现在对外提供的构建旁边,生产构建在它开启时会发出警告。用 --protectionMap.enabled=false 关闭它。
  • 你原始文件的备份。 CLI 在原地改写一个文件之前,会把原始文件复制到 .afterpack/backup/,并附带一份记录每个文件被混淆成了什么的清单。这个目录一字不差地保存着你的源码,只留在构建机器上,npx afterpack@latest restore 能把原始文件放回去。可以用 --build.backup=false 关闭这个行为。框架插件默认不写这份副本;把 build.backup 设为 true 后,它会改成在输出旁边写一个 .backup.<hash> 同级文件,而这个文件会随构建一起发布出去。
  • Source map 会把已混淆的输出直接链回原始源码。.map 同级文件在生产环境默认关闭,//# sourceMappingURL= 注释在生产环境也默认关闭。

插件和 CLI 通过 .afterpack/ 内的一个 .gitignore 让该目录不进入 git,从不修改你的 .gitignore,并在某个产物落在对外提供的路径下时发出警告。构建旁边的 .map 或 .backup.<hash> 文件需要你自己把它挡在 git 之外。

绝不要部署 source map 或备份

Source map 和备份都包含你的完整源码,两者都绝不能部署。

@afterpack/next 在构建之后还会删除对外提供的目录树中的每一个 .js.map 文件。

不发布 map 也能调试生产环境的堆栈

生产构建不会写出 .map,也不会写 //# sourceMappingURL= 注释,这是正确的默认行为,代价是你得手动完成堆栈的符号还原。如果你的团队确实需要生产环境的真实堆栈,应该构建出 map 并把它保密,而不是干脆跳过它。

为这次构建打开 map,同时保持指针关闭,让发布出去的 JavaScript 里没有任何东西暗示 map 的存在:

{
  "sourceMap": { "enabled": true, "emitUrl": false, "sourcesContent": true }
}

然后让 .map 文件不出现在部署目录中:把它们上传到你的错误追踪服务,或者作为 CI 产物保留,在那里完成符号还原。如果你的部署会复制整个构建目录,那就在边缘节点也拒绝这些文件,让针对它们的请求永远得不到响应。

location ~ \.map$ {
  return 404;
}

这条规则在任何环境下都有对应的写法:匹配 *.map 的 CDN 或 WAF 规则、代理层的 deny,或者一个压根不接收这些文件的存储桶。可以在你的网络之外用 curl -I https://yoursite.example.com/assets/app.js.map 来检查,预期得到 404;也可以用 npx afterpack@latest audit,它会把可访问的 map 作为一项发现报告出来。

拒绝规则是第二道防线,不是第一道

边缘规则保护的是已经在服务器上的文件。任何绕过它的情况,比如配置错误的路由、直接的存储桶 URL、或是预览部署,都会把内含你原始源码的 map 提供出去。压根不部署这些 map,才是不可能被配置错的那种做法。

如果你不小心发布了一个密钥

一个进入了你 bundle 的 API 密钥、令牌或密码,是一起正在发生的事件。AfterPack 能改变读者在发布文件里能找到什么,但不能改变这个凭证已经泄露出去的事实。

AfterPack 对它做了什么。 在默认预设下,字符串字面量会被编码,因此存放在字符串字面量里的密钥在你部署的文件里是不可读的。少数始终保持可读的字面量列在预设中。在 bundle 里搜索这个密钥,或者搜索它周围的文本,都什么都找不到。自动化的密钥扫描器和 bundle 爬虫同样一无所获,而这两者正是大多数泄露密钥被发现的途径。

AfterPack 没有做什么。 这个凭证仍然在构建产物里,你的代码仍然在使用它。任何在自己控制的浏览器里运行你应用的人,都能在代码使用这个值的那一刻拿到它。编码为你争取了响应的时间,但不会让一个客户端密钥变得安全。

该做什么,按顺序:

  1. 轮换这个密钥。 从构建发布出去的那一刻起,就把它当作已经泄露处理。这一步才能真正结束这起事件。
  2. 把权限判断移到服务端。 让你自己的后端持有这个凭证,并决定调用方是否可以拿到结果。一个浏览器从未收到过的密钥,不可能泄露。
  3. 检查现在线上的情况。 对你部署的站点运行 npx afterpack@latest audit。它会报告暴露的密钥、泄露的 source map,以及站点实际提供的 JavaScript 的保护级别,让你知道此刻一个读者能从生产环境中取出什么。
  4. 重新构建并重新部署,让携带旧值的那个 bundle 不再被提供。
混淆不能替代轮换

一个已经发布出去的凭证必须被轮换,无论它是用哪个预设构建出来的。

测试已混淆的构建

始终针对已混淆的构建运行你的集成测试。测试混淆之前的构建,不会告诉你任何关于实际发布内容的信息。

如果一个测试在未混淆的构建上通过,却在已混淆的构建上失败,通常指向两类问题之一:保留设置有缺口(一个运行时期望存在的名称被重命名了),或者存在一种未被识别的反射模式。诊断信息通常会直接指出能修复它的配置键。用 --diagnostics.level=all 运行,可以看到完整的每一条诊断,而不是汇总摘要。

构建通过就是唯一的信号

任何会导致发布出比你配置的更弱保护的情况,都会让构建直接失败,包括引擎无法混淆的文件,以及无法连上云端的 Pro 构建。诊断记录了完整的退出约定,以及背后的每一个代码。

下一步

  • 构建与 CI:同一份字节逐级晋级的生命周期、产物卫生,以及定期重建。
  • 威胁模型:这些指令所对准校准的攻击者梯度。
  • 配置:所有配置键汇总在一处。
  • 指令:上面那张表背后完整的区域标记语法。
  • 预设:这些指令按区域覆盖的五级梯度。
  • Protection Map:核实你标记的区域实际命中了哪个类别。
  • 控制台中的构建:针对你的 Pro Cloud 构建,逐次提供同样的数据。