探究
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 上下文。在这个上下文中,我们无法使用 fetch
和 XMLHttpRequest
,无法操作 DOM 树,无法更改 location
,甚至连 console
都无法使用。
然而,对于执行代码所必要的 eval
和 Function
构造器却仍然可以使用。 在使用它们执行代码时,代码的上下文 (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 捕获。