卷 IV · 墙CH 14深度 14/24

不设防会怎样:现场逃逸一次

宿主非常小心:它没有把 window 给插件,没有把 globalThis 给插件,只传了一个自己精心构造的 ctx然后插件用三行代码把整个世界拿了回去。这一章那段攻击代码是真跑的 —— 就在这个页面上,会真的取出一个我预先埋在宿主里的机密。

逃逸Function 构造器Proxy 的极限必输的比赛

先把宿主的自信立起来

假设你按前面十三章学到的一切,认认真真写了一个宿主。给插件的 ctx 是这样的 —— 干净、克制、什么多余的都没有:

const ctx = {
  log: (m) => console.log('[插件]', m),
  notes: {
    read: (id) => findNote(id)?.body ?? null,
  },
};

const factory = new Function('module', 'exports', pluginSource);
const mod = { exports: {} };
factory(mod, mod.exports);
mod.exports.activate(ctx);      // ← 插件只拿到 ctx

回顾一下你已经做对的事:

  • 用了 new Function 而不是 eval,所以插件看不见宿主的局部变量(第 9 章);
  • ctx 里只有两样东西,没有任何内部对象泄漏(第 5 章);
  • 整个调用包在 try/catch 里,插件抛异常崩不了宿主(第 6 章)。

看起来相当稳。插件除了 lognotes.read,什么都没有。

现在看插件那边。

三行

module.exports = {
  activate(ctx) {
    const F = ctx.log.constructor;              // ① ctx.log 是个函数,函数的构造器是 Function
    const global = F('return globalThis')();    // ② 用 Function 构造器造一个新函数并执行
    // ③ 出去了。整个世界都在 global 里。
  }
};

拆开看这三行在干什么:

ctx.log 是一个函数。所有函数都有 .constructor,指向内置的 Function 构造器。宿主没给它 Function,但它从自己拿到的任何一个函数身上摸到了。

Function('return globalThis') 编译出一个新函数。而第 9 章讲过:new Function 造出的函数,作用域链的父节点是全局作用域。所以在它内部,globalThis 就是真正的全局对象。

③ 执行它,拿到返回值。现在插件手里有了 windowdocumentfetchlocalStorage…… 整个页面。

看它真的跑

下面这个 demo 里,我在宿主的全局上埋了一个假的机密 __HOST_SECRET点「在朴素宿主里跑」,那段攻击代码会真的执行,真的把它取出来:

然后点第二个按钮 —— 「加固过的宿主」。加固做了这些事:

const safeLog = function (m) { /* … */ };
// 把通往 Function 构造器的路堵掉
Object.defineProperty(safeLog, 'constructor', {
  value: undefined, writable: false, configurable: false
});
const ctx = Object.freeze({ log: safeLog, notes: Object.freeze({ read }) });
Object.setPrototypeOf(ctx, null);      // 连原型链都断掉

这一发被挡住了。但请不要因此感到安心 —— 下面是这一章真正的内容。

为什么这场比赛你必输

堵掉 .constructor 只堵掉了一条路。还有多少条?

// 只要能摸到任何一个函数、对象、数组、Promise……都能回到 Function
ctx.notes.read.constructor                        // 另一个函数
Object.getPrototypeOf(ctx.notes.read).constructor // 绕过属性覆盖
(function(){}).constructor                        // 插件自己造一个函数!
(() => {}).constructor
[].map.constructor                                // 数组方法
Promise.resolve().then.constructor
JSON.parse.constructor
({}).toString.constructor
async function f(){}; f.constructor               // AsyncFunction
function* g(){}; g.constructor                    // GeneratorFunction

看第三行。插件可以自己写一个函数,然后从它身上拿 constructor

你要怎么阻止这个?插件的代码里写 function(){} 是完全合法的 JavaScript —— 你没法禁止插件定义函数。

◆ 这就是问题的核心

插件的代码和你的代码,跑在同一个 JS 引擎里,共享同一套内置对象。

而 JavaScript 的内置对象是互相连通的:函数能到 FunctionFunction 能编译任意代码,编译出的代码能看见全局。这条路是语言规范定义的,不是实现漏洞。

你能做的只是把已知的路一条条堵上。而攻击者只需要找到一条你没堵的。

这是一场你必须次次全对、对方只要对一次的比赛。而且规则每次语言更新都可能新增几条路。

这不是理论。第 12 章提过 Node 生态里的 vm2 —— 一个专门为「安全运行不可信代码」而生的库,由懂行的人维护了很多年,反复被发现逃逸漏洞,最终作者宣布停止维护并建议大家别再用它做安全边界。那是一群很聪明的人认真打了很多年之后得出的结论。

Node 官方文档对 vm 模块的态度也很干脆:它不是安全机制,不要用它跑不可信代码。

那 Proxy 呢?

很多人的下一个念头是:用 Proxy 包一层,拦截所有属性访问。

const guarded = new Proxy(realCtx, {
  get(target, prop) {
    if (!ALLOWED.has(prop)) return undefined;     // 白名单之外一律 undefined
    return target[prop];
  }
});

Proxy 确实有用,但它解决的是另一个问题。看清楚它管得到什么、管不到什么:

Proxy 能做到Proxy 做不到
限制插件从 ctx 上能拿到哪些属性限制插件访问全局的 documentfetch
拦截读写,加日志、加权限检查阻止插件自己造函数、拿到 Function
把一个大对象「削」成一个小接口阻止插件污染 Object.prototype
让越权访问变成显式的错误阻止插件死循环、耗尽内存
◆ 一句话分清

Proxy 是权限工具,不是隔离工具。

它管的是「我给你的这个对象上,你能碰什么」;它完全管不到「你从别处能摸到什么」。

所以 Proxy 在第 17 章(权限)里会是主角 —— 它非常适合做那件事。但如果你指望它当沙箱,你只是给攻击者增加了三十秒的工作量。

那这些防护是不是白做了

不是。但要把它们放在正确的位置上理解。

一个好的 ctx 设计 + 权限闸门,防住的是这三类情况 —— 而它们恰恰是日常最高频的:

防住的例子频率
误用插件作者不知道某个 API 不该在这里调,被拦下并得到清晰报错极高
不知情的越界插件用了一个第三方库,那个库偷偷读了文件 —— 被闸门发现
依赖内部实现插件想拿宿主的私有对象搞事,拿不到,于是被迫用公开 API
蓄意的恶意代码防不住

最后一行是这一章要你记住的:同进程的所有防护,都防不住蓄意的恶意代码。

这不代表它们没价值 —— 恰恰相反,前三行的价值巨大,它们让插件生态健康、让 bug 可诊断、让宿主保有重构自由。但你必须知道自己买的是什么,不能拿着一个防误用的工具去当安全边界用,然后告诉用户「我们的插件是沙箱化的」。

⚠ 最危险的不是没有沙箱,是以为自己有

Obsidian 的做法虽然「不设防」,但它诚实:官方文档明确写着无法可靠限制插件权限、插件能访问你电脑上的文件、默认开启受限模式。用户因此知道自己在承担什么风险,并据此决定装谁的插件。

真正危险的是另一种系统:用 Proxy 和 Object.freeze 搭了一个「沙箱」,在文档里写「插件运行在安全的沙箱环境中」,于是用户放心地装任何插件 —— 而那个沙箱三行代码就能出去。

安全承诺的边界必须说准。说少了用户会更谨慎(没坏处),说多了用户会放松警惕(有人要付代价)。

真正的隔离,只能来自运行时之外

既然同一个运行时里关不住,答案就只有一个:让插件跑在一个从一开始就没有你那些东西的运行时里。

注意这句话的措辞 —— 不是「把它不该有的拿走」,而是「它一开始就没有」。这是两种完全不同的安全模型:

拿走式(黑名单)没有式(白名单)
做法在共享环境里堵住已知的路换一个空环境,只加你要给的
漏一个的后果整个防线失效插件少一个功能
语言更新时可能出现新的逃逸路径不受影响
例子Proxy 沙箱、vmvm2Worker、iframe、独立进程、WASM

「漏一个的后果」那一行是全部区别。黑名单模型下,失误是灾难性的;白名单模型下,失误只是不方便。

一个 Worker 里没有 document、没有 localStorage、没有你的 DOM。插件在那里面照样能拿到 globalThisFunction 构造器还在,这是语言特性),但那个 globalThis 是一间空屋子。逃逸「成功」了,也无处可去。

第 16 章会把这三种环境并排跑一遍,让你亲眼看到同一段探针代码在 Worker 里摸到的是什么。

装到自己身上

◆ 三个可以立刻做的判断

① 检查你的文档里有没有「沙箱」这个词。如果有,而你的插件跑在同一个运行时里,把它改掉。改成「插件运行在与宿主相同的进程中,拥有与本应用相同的权限」—— 诚实的描述会让用户做出更好的决策。

② 分清你在防谁。写下来:我要防的是「粗心的插件作者」还是「蓄意的攻击者」?
前者 → ctx 设计 + 权限闸门 + 清晰报错,性价比极高,今天就能做。
后者 → 只有运行时隔离一条路,请老实评估工程量,别指望语言技巧。

③ 如果暂时做不了隔离,那就把力气花在「可归责」上。这是 Mihon 的策略(第 11 章):既然挡不住恶意代码,那就保证出事之后能准确知道是谁 —— 签名、来源记录、权限申报、行为日志。可归责性不能阻止第一次攻击,但它能阻止第二次,并且让作者的信誉成为真实的约束。

◆ 顺带说:JVM 上也是同样的故事

Java 曾经有一个专门干这件事的机制:SecurityManager —— 在同一个 JVM 里,用权限策略限制代码能做什么。它在 Applet 时代被寄予厚望。

结局是:它在 JDK 17 中被正式废弃,并在后续版本中被移除。官方给出的理由和本章说的是同一件事 —— 在共享运行时里做安全沙箱,维护成本极高、正确性难以保证,而且现实中几乎没人正确使用它。

所以在 JVM 上也一样:不要指望「同进程 + 权限检查」当安全边界。要真隔离,就上独立进程(配合系统级的用户权限、容器、seccomp),或者干脆别让不可信代码进来。

这一章的一句话

同一个运行时里的沙箱是一场你必须次次全对、攻击者只要对一次的比赛;ctx 设计和 Proxy 是优秀的权限工具(防误用、防不知情的越界),但它们不是隔离工具 —— 而最危险的状态,是以为自己有沙箱。

下一章换一种伤害方式:插件不偷东西,它只是不返回。你会真的感受到那 0.7 秒 —— 因为卡住的是你正在看的这个页面。