【实验】滥用同域 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 捕获。

相关推荐
静小谢7 小时前
前后台一起部署,vite配置笔记base\build
前端·javascript·笔记
用户47949283569157 小时前
改了CSS刷新没反应-你可能不懂HTTP缓存
前端·javascript·面试
还好还好不是吗8 小时前
老项目改造 vue-cli 2.6 升级 rsbuild 提升开发效率300% upupup!!!
前端·性能优化
sumAll8 小时前
别再手动对齐矩形了!这个开源神器让 AI 帮你画架构图 (Next-AI-Draw-IO 体验)
前端·人工智能·next.js
OpenTiny社区8 小时前
2025OpenTiny星光ShowTime!年度贡献者征集启动!
前端·vue.js·低代码
wangan0948 小时前
不带圆圈的二叉树
java·前端·javascript
狗哥哥8 小时前
从零到一:打造企业级 Vue 3 高性能表格组件的设计哲学与实践
前端·vue.js·架构
疯狂平头哥8 小时前
微信小程序真机预览-数字不等宽如何解决
前端
Drift_Dream8 小时前
前端趣味交互:如何精准判断鼠标从哪个方向进入元素?
前端
hqk8 小时前
鸿蒙ArkUI:状态管理、应用结构、路由全解析
android·前端·harmonyos