VM 防御遥测与响应
使用 vmDefenseHook 将 VM 防御检测结果上报到您的后端,并用 vmDefenseReaction 调整每个检测类别的响应方式 - 从完全不破坏、仅遥测的构建,到一个对被窃取的包立即中断的构建。
观看
Obfuscator.io Defense Reactions: Break, Decoy, and the VM Defense Hook
问题
VM 防御 - vmSelfDefending、vmDebugProtection 与 vmDomainLock - 是就地生效的:当检测到调试器、自动化工具、被篡改的环境或未授权的域名时,受保护的代码会中断,或静默地毒化自身的结果。这能阻止攻击者,但默认情况下 您对此一无所知。您无从得知您的包被探测的频率、是哪个检测器触发的,也不知道某项防御是否正在破坏合法用户的使用。
有两个选项填补了这一空白。它们都不会 启用 任何防御 - 只是观察并引导您已经开启的防御:
vmDefenseHook- 一个全局回调,每当某项防御检测到什么时都会收到一个信号对象。用它把遥测发送到您的后端。vmDefenseReaction- 一个按类别的映射,用于选择已启用的防御 如何 响应:中断、decoy,或在本地什么都不做。
这两个选项都是在 v7.1.0 中引入的,但这里的每个示例都使用对象形式 vmDefenseHook: { name },它需要 v7.4.0。更早的版本接受一个纯字符串(vmDefenseHook: '__vmDetection');从 v8.0.0 起,这种形式会被拒绝,因此请始终使用对象形式。
方案 1 - 将检测结果上报到您的后端
步骤 1 - 在混淆后的包加载之前,注册一个全局钩子函数
VM 运行时及其防御在您受保护的程序 之前 运行,因此许多检测会在启动期间触发。请在宿主页面中、混淆脚本标签之前,把钩子定义为一个普通的全局变量:
步骤 2 - 让 vmDefenseHook 指向它
该选项是一个对象,其 name 是要调用的全局函数(aliases 是可选的 - 见下文):
在控制台中,一旦启用了至少一项防御(vmSelfDefending、vmDebugProtection 或 vmDomainLock),高级保护部分中就会出现 VM Defense Hook 字段。
步骤 3 - 在您的后端接收信号
每次检测都会用单个 signal 对象调用钩子:
source- 具体的检测器:headless、node、agent、agentBrowser、domain、debugger、sandbox、nativeHook、timing或integrity。自 v7.4.0 起,原先的env和inspector检测器改在source: 'debugger'下上报。agentBrowser在category: 'automation'下上报,并在启用vmDebugProtection的浏览器目标上运行(v7.9.0+)。category-automation、debugger、sandbox、domain、tamper或integrity。node来源在category: 'debugger'下上报(v7.4.0+)。score、threshold- 检测得分及其越过的阈值
一个最小的接收端点(此处以 Express 为例;任何接受 POST 的后端都可以)。它会把请求体规整为数组,因此也能处理下方缓冲模式所提交的批量形式:
该钩子 仅用于上报 - 它的返回值会被忽略,缺失或抛出异常的钩子都是静默的空操作。它永远无法禁用某项防御,因此攻击者删除或破坏您的钩子也一无所获。要改变某项防御的 行为,请使用 vmDefenseReaction(方案 2)。
请在宿主页面中定义钩子,而不要放在混淆源码内部
做遥测时您希望捕获每一次检测,而许多检测会在启动阶段触发 - 定义在混淆包 内部 的钩子注册得太晚,无法捕获这些检测,而且如果它被 VM 编译,就要等到您的程序运行时才能被访问到。无论哪种情况它都是安全的(缺失的钩子会空操作,自身触发检测的钩子也不会被再次递归调用),但为了完整覆盖,请在宿主页面中提前注册它。
唯一的例外是一个 只 对运行时检测作出响应的钩子 - 例如在使用过程中调试器打开时进行清理。这样的钩子可以放在混淆包内部;见方案 3。
要同时保护您的上报逻辑,请把已注册的钩子保持为一行缓冲,并从您的混淆代码中把它抽干:
重命名信号字段(aliases)
默认的 source / category 值都是描述性的名称,因此任何为该回调埋点(或阅读输出)的人都能识别出这项保护以及是哪个检测器触发的。aliases 会把信号字段重命名为您所选择的不透明令牌,且在信号发出 之前 于 VM 内部应用,因此这些名称永远不会出现在输出中或到达回调。您的应用知道自己的映射关系,并把这些令牌转发到您的后端。
别名是按字段设置的:每个字段接受一个 key(回调收到的属性名);字符串名称字段 source 和 category 还接受一个 values 映射,而 score / threshold 是数字,只接受 key。未设置的条目保持其默认名称。
在控制台中,信号别名 一节位于 VM Defense Hook 字段之下。
这是 规避指纹,而非保密 - 该映射仍可通过反复测试推断出来,因此它唯一的好处是不暴露稳定、见名知意的名称。
方案 2 - 调整默认响应
vmDefenseReaction 配置每个检测 类别 如何响应。它不会启用任何东西 - 防御本身由 vmSelfDefending、vmDebugProtection 和 vmDomainLock 开启;此选项只选择已启用的防御如何响应。类别是控制的单位:一个类别中的每个检测器都会执行该类别的响应,而为一个其选项处于关闭状态的类别所设置的响应,根本不会有任何效果。
| 类别 | 由谁启用 | 何时响应 |
|---|---|---|
automation | vmSelfDefending 或 vmDebugProtection | 代码正被软件而非真人驱动:无头或自动化浏览器、抓取 / 测试框架,或正在逐步执行页面的 AI 编码智能体。 |
debugger | vmDebugProtection 或 vmSelfDefending | 有人打开了调试器或浏览器的开发者工具检查器,正在单步执行运行中的代码以理解它。 |
sandbox | vmDebugProtection | 代码根本不在真实浏览器中运行 - 它已被提取到一个被模拟或脚本化的 JavaScript 环境中,以便离线执行和研究。 |
domain | vmDomainLock | 代码正运行在您未授权的站点上:一个不在您 vmDomainLock 允许列表中的主机(例如,您的包被复制到了别人的域名上)。 |
tamper | vmSelfDefending | VM 周围的 JavaScript 环境已被修改以监视或劫持它,例如原生浏览器内置对象被替换成了被插桩的版本。 |
integrity | vmSelfDefending | 受保护的包自您生成以来,其自身代码已被编辑或打过补丁。 |
键是这六个类别名称,或 default(针对未指定类别的回退)。值为:
break- 立即中断decoy- 在被毒化的状态上继续运行,静默地产生错误结果。decoy需要在浏览器目标上启用vmDebugProtection或vmDomainLock;否则它的行为等同于break。none- 在本地什么都不做(仅遥测)
您未设置的类别会回退到内置默认值:
default 会触及 每一个 类别,包括 integrity 和 tamper,因此 { default: 'none' } 是一个真正不破坏、仅遥测的构建:
在控制台中,一旦启用某项防御,高级保护部分中就会出现 VM Defense Reactions 选择器;只有在发出其检测器的某项防御处于开启状态时,对应类别才可编辑。
方案 3 - 在防御中断之前运行您自己的逻辑
钩子不仅仅用于上报 - 它也是在防御作出响应 之前 运行您自己响应逻辑的唯一可靠位置。当调试器在运行中的页面上打开时,您可能想清除屏幕上的内容,或把视图替换为 404 页面,并在代码中断之前完成。
为什么要用钩子,而非应用中其他地方的代码:break 会停止后续所有字节码,因此在防御触发 之后 才运行的清理逻辑 - 尤其当它自身也被 VM 混淆时 - 恰恰是 break 会阻止其执行的东西。钩子会在检测点、响应被执行 之前 同步触发 - 因此它调用的同步函数会先完成,然后 break 再停止 VM。
请把响应定义为您的 vmDefenseHook。由于 debugger 检测在运行时触发 - 即在您的程序已加载并定义了钩子之后 - 该钩子可以作为您混淆源码的一部分,并与包的其余部分一起被字节码化。基于 signal.category 分支处理,让每种情况都得到正确的响应,保持工作同步,然后让响应自行运行:
这适用于在您的应用运行期间触发的检测 - 请参阅下文的字节码化钩子何时有效。
请记住以下几点:
- 只有同步工作才保证先于响应完成。 响应会在钩子返回后紧接着的那条语句上运行。立即移交的即发即忘调用没有问题(
navigator.sendBeacon、同步的 DOM 与 canvas 编辑);而您安排到之后执行的工作 -setTimeout、promise 的后续处理、await- 则不行,任何需要更多 VM 字节码的操作都不会运行,因为那正是break所停止的。 - 钩子在响应之前运行;它并不取代响应。 它的返回值会被忽略,也无法取消、延迟或改变响应的行为。请用它在中断之前采取行动,而不是否决中断 - 要改变响应本身,请使用
vmDefenseReaction(方案 2)。
字节码化钩子何时有效
像这样把钩子放在混淆包内部之所以可行,只是因为 debugger 检测在运行时触发。VM 会在自身仍然存活时触发 vmDefenseHook,即在您的程序已加载并定义了钩子之后,因此字节码化的钩子会先被解码并运行,然后才是 break。这正是保护钩子自身源码的原理。
对于在 启动阶段 触发的检测 - automation、sandbox、domain,或页面加载时就已打开的调试器 - 它 不会 生效,因为那时字节码化的钩子还没有定义,防御找不到可调用的函数。对于这些情况,请改为把钩子注册为宿主页面中的普通全局变量,做法与方案 1 相同。拿不准时,宿主页面的普通全局变量能覆盖每一个到达钩子的检测;字节码化只是为钩子自身的源码增加保护,且仅限于运行时检测。
从遥测到强制执行
可见性与强制执行不必同时上线。分两个阶段部署这些防御:先发布一个只上报的构建,然后 - 一旦遥测看起来干净 - 再发布一个会响应的。
步骤 1 - 发布一个仅观察的构建
启用您计划使用的每一项防御,让 vmDefenseHook 指向您的端点,并关闭所有响应。每个检测器仍会运行,并把每次命中上报到您的后端,但不会破坏任何东西:
步骤 2 - 审阅收集到的信号
在该构建见过真实流量之后,查找由合法使用触发的检测。最常见的两种:
- 来自您自己的端到端测试或可用性监控的
automation命中 - 请构建那些不带防御的产物,而不是在生产中容忍该类别。 - 来自某个您忘记加入
vmDomainLock允许列表的预发或预览主机的domain命中 - 把该主机加进去。
优先修复根因,而不是弱化响应:每一个保持在 none 的类别,都是攻击者可以放心忽略的检测器。
步骤 3 - 开启响应
移除 default: 'none' 覆盖,让内置的按类别响应生效;那一行就是全部改动。如果某个类别持续产生您无法消除的误报,就只把该类别保持在 none(例如 vmDefenseReaction: { automation: 'none' }),并对其余的强制执行。
强制执行开启后请继续保留 vmDefenseHook - 无论响应如何,钩子都会触发,因此在防御运作的同时,您仍能保持对谁在探测您的包的可见性。
