不设防会怎样:现场逃逸一次
宿主非常小心:它没有把 window 给插件,没有把 globalThis 给插件,只传了一个自己精心构造的 ctx。然后插件用三行代码把整个世界拿了回去。这一章那段攻击代码是真跑的 —— 就在这个页面上,会真的取出一个我预先埋在宿主里的机密。
先把宿主的自信立起来
假设你按前面十三章学到的一切,认认真真写了一个宿主。给插件的 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 章)。
看起来相当稳。插件除了 log 和 notes.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 就是真正的全局对象。
③ 执行它,拿到返回值。现在插件手里有了 window、document、fetch、localStorage…… 整个页面。
看它真的跑
下面这个 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 的内置对象是互相连通的:函数能到 Function,Function 能编译任意代码,编译出的代码能看见全局。这条路是语言规范定义的,不是实现漏洞。
你能做的只是把已知的路一条条堵上。而攻击者只需要找到一条你没堵的。
这是一场你必须次次全对、对方只要对一次的比赛。而且规则每次语言更新都可能新增几条路。
这不是理论。第 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 上能拿到哪些属性 | 限制插件访问全局的 document、fetch |
| 拦截读写,加日志、加权限检查 | 阻止插件自己造函数、拿到 Function |
| 把一个大对象「削」成一个小接口 | 阻止插件污染 Object.prototype |
| 让越权访问变成显式的错误 | 阻止插件死循环、耗尽内存 |
Proxy 是权限工具,不是隔离工具。
它管的是「我给你的这个对象上,你能碰什么」;它完全管不到「你从别处能摸到什么」。
所以 Proxy 在第 17 章(权限)里会是主角 —— 它非常适合做那件事。但如果你指望它当沙箱,你只是给攻击者增加了三十秒的工作量。
那这些防护是不是白做了
不是。但要把它们放在正确的位置上理解。
一个好的 ctx 设计 + 权限闸门,防住的是这三类情况 —— 而它们恰恰是日常最高频的:
| 防住的 | 例子 | 频率 |
|---|---|---|
| 误用 | 插件作者不知道某个 API 不该在这里调,被拦下并得到清晰报错 | 极高 |
| 不知情的越界 | 插件用了一个第三方库,那个库偷偷读了文件 —— 被闸门发现 | 中 |
| 依赖内部实现 | 插件想拿宿主的私有对象搞事,拿不到,于是被迫用公开 API | 高 |
| — | 蓄意的恶意代码 | 防不住 |
最后一行是这一章要你记住的:同进程的所有防护,都防不住蓄意的恶意代码。
这不代表它们没价值 —— 恰恰相反,前三行的价值巨大,它们让插件生态健康、让 bug 可诊断、让宿主保有重构自由。但你必须知道自己买的是什么,不能拿着一个防误用的工具去当安全边界用,然后告诉用户「我们的插件是沙箱化的」。
Obsidian 的做法虽然「不设防」,但它诚实:官方文档明确写着无法可靠限制插件权限、插件能访问你电脑上的文件、默认开启受限模式。用户因此知道自己在承担什么风险,并据此决定装谁的插件。
真正危险的是另一种系统:用 Proxy 和 Object.freeze 搭了一个「沙箱」,在文档里写「插件运行在安全的沙箱环境中」,于是用户放心地装任何插件 —— 而那个沙箱三行代码就能出去。
安全承诺的边界必须说准。说少了用户会更谨慎(没坏处),说多了用户会放松警惕(有人要付代价)。
真正的隔离,只能来自运行时之外
既然同一个运行时里关不住,答案就只有一个:让插件跑在一个从一开始就没有你那些东西的运行时里。
注意这句话的措辞 —— 不是「把它不该有的拿走」,而是「它一开始就没有」。这是两种完全不同的安全模型:
| 拿走式(黑名单) | 没有式(白名单) | |
|---|---|---|
| 做法 | 在共享环境里堵住已知的路 | 换一个空环境,只加你要给的 |
| 漏一个的后果 | 整个防线失效 | 插件少一个功能 |
| 语言更新时 | 可能出现新的逃逸路径 | 不受影响 |
| 例子 | Proxy 沙箱、vm、vm2 | Worker、iframe、独立进程、WASM |
「漏一个的后果」那一行是全部区别。黑名单模型下,失误是灾难性的;白名单模型下,失误只是不方便。
一个 Worker 里没有 document、没有 localStorage、没有你的 DOM。插件在那里面照样能拿到 globalThis(Function 构造器还在,这是语言特性),但那个 globalThis 是一间空屋子。逃逸「成功」了,也无处可去。
第 16 章会把这三种环境并排跑一遍,让你亲眼看到同一段探针代码在 Worker 里摸到的是什么。
装到自己身上
① 检查你的文档里有没有「沙箱」这个词。如果有,而你的插件跑在同一个运行时里,把它改掉。改成「插件运行在与宿主相同的进程中,拥有与本应用相同的权限」—— 诚实的描述会让用户做出更好的决策。
② 分清你在防谁。写下来:我要防的是「粗心的插件作者」还是「蓄意的攻击者」?
前者 → ctx 设计 + 权限闸门 + 清晰报错,性价比极高,今天就能做。
后者 → 只有运行时隔离一条路,请老实评估工程量,别指望语言技巧。
③ 如果暂时做不了隔离,那就把力气花在「可归责」上。这是 Mihon 的策略(第 11 章):既然挡不住恶意代码,那就保证出事之后能准确知道是谁 —— 签名、来源记录、权限申报、行为日志。可归责性不能阻止第一次攻击,但它能阻止第二次,并且让作者的信誉成为真实的约束。
Java 曾经有一个专门干这件事的机制:SecurityManager —— 在同一个 JVM 里,用权限策略限制代码能做什么。它在 Applet 时代被寄予厚望。
结局是:它在 JDK 17 中被正式废弃,并在后续版本中被移除。官方给出的理由和本章说的是同一件事 —— 在共享运行时里做安全沙箱,维护成本极高、正确性难以保证,而且现实中几乎没人正确使用它。
所以在 JVM 上也一样:不要指望「同进程 + 权限检查」当安全边界。要真隔离,就上独立进程(配合系统级的用户权限、容器、seccomp),或者干脆别让不可信代码进来。
这一章的一句话
同一个运行时里的沙箱是一场你必须次次全对、攻击者只要对一次的比赛;ctx 设计和 Proxy 是优秀的权限工具(防误用、防不知情的越界),但它们不是隔离工具 —— 而最危险的状态,是以为自己有沙箱。
下一章换一种伤害方式:插件不偷东西,它只是不返回。你会真的感受到那 0.7 秒 —— 因为卡住的是你正在看的这个页面。