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

相关推荐
苏卫苏卫苏卫21 分钟前
【Vue】案例——To do list:
开发语言·前端·javascript·vue.js·笔记·list
0509151 小时前
测试基础笔记第四天(html)
前端·笔记·html
聪明的墨菲特i1 小时前
React与Vue:哪个框架更适合入门?
开发语言·前端·javascript·vue.js·react.js
时光少年1 小时前
Android 副屏录制方案
android·前端
拉不动的猪1 小时前
v2升级v3需要兼顾的几个方面
前端·javascript·面试
时光少年1 小时前
Android 局域网NIO案例实践
android·前端
半兽先生2 小时前
VueDOMPurifyHTML 防止 XSS(跨站脚本攻击) 风险
前端·xss
冴羽2 小时前
SvelteKit 最新中文文档教程(20)—— 最佳实践之性能
前端·javascript·svelte
Jackson__2 小时前
面试官:谈一下在 ts 中你对 any 和 unknow 的理解
前端·typescript
zpjing~.~2 小时前
css 二维码始终显示在按钮的正下方,并且根据不同的屏幕分辨率自动调整位置
前端·javascript·html