【实验】滥用同域 iframe 创建高安全性 js 沙箱的方法

探究

2 年前在把玩 Proxy 和 vm 的时候,突然发现了 iframe 的一个有意思的特性。

众所周知,在 iframe src 指向的域名和 iframe 满足 sameorigin 关系,并且在 sandbox 属性中启用了 Javascript(不指定则默认启用)的时候,去访问其元素对象下的 contentWindow 即可获取到 iframe 自身的 Window。

用 JavaScript 代码来说:

js 复制代码
const iframe = document.createElement('iframe');
iframe.src = 'about:blank';
iframe.style.display = 'none';
document.head.appendChild(iframe); // 将 iframe 加入到元素树中才会真正加载其内容,否则 contentWindow 为 null
const contentWindow = iframe.contentWindow;
contentWindow.console.log('来自 iframe');

这很好,然而跟标题有什么关系呢?要知道,在满足 sameorigin 关系且允许 Javascript 时,iframe 中也可以使用如下代码对宿主进行操作:

js 复制代码
window.top.console.log('沙箱逃逸');

通常,在 iframe 中的代码不可信任的情况下,我们会避免将 iframe 设置为 sameorigin,并且使用 window.postMessage 而非访问 contentWindow 进行通信,然而这样做我们便不能同步地访问 iframe 的内容。这和 Worker 的缺点是一样的。

近些年流行的 微前端,例如 qiankun,为了解决这种限制,对待用户代码采取类似如下的措施:

js 复制代码
new Function("window", "document", code)(new Proxy(...), new Proxy(...))

这样的方式比较需要用户合作。如果用户在代码中使用 delete window; window,沙箱就会被打破。当然,qiankun 本身也不是用来运行不可信任的代码的。类似的,我们有如 vm2 等库,也是基于 Proxy 实现的(这些库由于并没有新建 World,导致几乎都可以用 栈溢出异常 以及 dynamic import 实现沙箱逃逸)。

最新的 ses 提案一开始用 iframe 实现,现在则使用语法树解析 + Object.freeze 实现了。虽然同时解决了沙箱逃逸和同步通信的问题,然而缺点也显而易见:应用 ses 会对全局的各种对象造成污染,并且显著降低 new Function 的速度。一些老代码可能不会兼容 ses,不过总体来讲如果没有洁癖,ses 倒还是好的。

那么有没有办法在不污染全局作用域的情况下,在防止沙箱逃逸的同时允许同步通信呢?答案是------有的

实验

回到我们最开始提到的代码,这次我们尝试移除 iframe。显然,在移除 iframe 之后,由于页面被销毁,contentWindow 应当重新变为 null。事实证明 iframe 的行为确实如此。

js 复制代码
const iframe = document.createElement('iframe');
iframe.src = 'about:blank';
iframe.style.display = 'none';
document.head.appendChild(iframe);
console.log(iframe.contentWindow); // Window {}
iframe.remove();
console.log(iframe.contentWindow); // null

那么,如果我们在 iframe 销毁后仍然持有 contentWindow 的引用会如何呢?

js 复制代码
const iframe = document.createElement('iframe');
iframe.src = 'about:blank';
iframe.style.display = 'none';
document.head.appendChild(iframe);
const contentWindow = iframe.contentWindow;
iframe.remove();
console.log(contentWindow); // ???

看起来浏览器也没想到我们会这么做。在销毁 iframe 时,浏览器成功销毁了 DOM 树和页面需要用到的其它资源,然而由于我们持有一个对 Window 的引用,因此浏览器无法销毁 JavaScript 上下文。

这样,我们便得到了一个 detached (分离) 的 JavaScript 上下文。在这个上下文中,我们无法使用 fetchXMLHttpRequest,无法操作 DOM 树,无法更改 location,甚至连 console 都无法使用。

然而,对于执行代码所必要的 evalFunction 构造器却仍然可以使用。 在使用它们执行代码时,代码的上下文 (globalThis 和作用域),甚至抛出的 Error 仍然属于 iframe contentWindow,不能获取到任何 iframe 以外的内容从而引发基于 constructor 的沙箱逃逸。

进一步地,我们可以向 contentWindow 中设置内容,从而允许沙箱中的代码访问有限的 API,并直接与宿主通信。例如:

js 复制代码
const contentWindow = ...;
contentWindow.A = class {};

然而,如果不保护好对象的 constructor,就可以利用 constructor 进行逃逸(因为设置的对象是宿主创建的,所以 .constructor.constructor 就会指向宿主的 Function,这一点对字面量无效)。

js 复制代码
A.constructor.constructor("return globalThis")();

因此,还需要配合 ES6 Proxy 才能确保完全的安全性。我在这里抛砖引玉,自己编写了一个基于这种方法实现的 代码沙箱,整个库仅有一个代码文件,欢迎参考。

另注

由于浏览器(包括 whatwg)都没有想到这种近乎于滥用的使用方式,部分浏览器会出现莫名奇妙的问题。以下是我经调查和汇报 issue 整理出的一个兼容性表格:

浏览器 兼容性
Chromium 系
Firefox 系 ✅ *1
Safari
Opera
移动端 (除 Firefox 外)

*1:在尝试访问部分全局属性(包括 delete),例如 window.screen 时会引发未知内部异常。可以被 try-catch 捕获。

相关推荐
m0_471199632 分钟前
【自动化】前端开发,如何将 Jenkins 与 Gitee 结合实现自动化的持续集成(构建)和持续部署(发布)
前端·gitee·自动化·jenkins
w***95493 分钟前
spring-boot-starter和spring-boot-starter-web的关联
前端
Moment7 分钟前
富文本编辑器技术选型,到底是 Prosemirror 还是 Tiptap 好 ❓❓❓
前端·javascript·面试
xkxnq11 分钟前
第二阶段:Vue 组件化开发(第 18天)
前端·javascript·vue.js
晓得迷路了13 分钟前
栗子前端技术周刊第 112 期 - Rspack 1.7、2025 JS 新星榜单、HTML 状态调查...
前端·javascript·html
怕浪猫15 分钟前
React从入门到出门 第五章 React Router 配置与原理初探
前端·javascript·react.js
jinmo_C++16 分钟前
从零开始学前端 · HTML 基础篇(一):认识 HTML 与页面结构
前端·html·状态模式
鹏多多22 分钟前
前端2025年终总结:借着AI做大做强再创辉煌
前端·javascript
小Tomkk30 分钟前
⭐️ StarRocks Web 使用介绍与实战指南
前端·ffmpeg
不一样的少年_34 分钟前
产品催: 1 天优化 Vue 官网 SEO?我用这个插件半天搞定(不重构 Nuxt)
前端·javascript·vue.js