2024-08-21 接觸資安才發現我不懂前端about XSS

2024-08-21 接觸資安才發現我不懂前端about XSS

2021年10月25日
Security
此文章是我在 Modern Web 2021 的分享:《接觸資安才發現前端的水真深》的文字版,當時的演講影片尚未釋出,想看簡報的話在這邊:slides

我自己覺得影片加上簡報的效果應該會比文字好,但想說用文字留一份紀錄也不錯,因此還是寫了這篇文章,內容會有些許與影片不同,有點像是再重新寫了一遍。

原來我不懂前端
這個標題是我接觸到資安的世界以後,最真實的想法。

身為一個前端工程師,自認為對前端頗為熟悉,無論是原生的 JavaScript 還是一些框架或函式庫,多少都用過或者聽過,連看到許多奇形怪狀的 JavaScript 題目也不會感到太過驚訝,覺得已經沒什麼能讓我「哇!」這樣的驚嘆。

直到我接觸了資安相關的東西,才知道是我太天真。

前端工程師所接觸的前端,跟資安工程師所看到的前端是兩個不同的面向。資安的重點在於各式各樣的攻擊手法,要想辦法繞過既有的限制,找到一條新的道路。但是前端工程師根本不需要知道那些東西,因為他是在沒有限制的狀況下來寫 code 的。

這陣子有玩了一些 CTF,對於前端也從另一個角度看了一陣子,學到很多新的前端知識,換句話說,我在一個新的領域(資安)重新學習到了我原本熟悉的領域(前端)的知識,這種感覺十分特別,因此這篇文章想跟大家分享我學到的一些東西,希望能讓大家感受到我當初的驚訝。

此文章分成三個主題:

繞過各種限制
XS leaks
其他你可能不知道的功能
繞過各種限制
談到前端安全,第一個想到的想必是 XSS,有關 XSS 的文章跟介紹之前寫過不少了,有興趣的話可以參考:XSS 從頭談起:歷史與由來,這邊就不再贅述。

一個最簡單直覺的 XSS payload 會長這樣:

但這種形式的 XSS 不夠有趣,而且很容易被防禦住,所以我們先暫時不講這個,來看一些比較有趣的,例如說這個:

這一段 HTML 利用 event handler 的方式去執行 JavaScript,我們載入一張不存在的圖片,就會觸發 onerror 事件,執行到裡面的程式碼。還有一點值得注意的是,其實屬性不需要加 "" 也可以。

還可以再更進一步,變成這樣:

這次連 src 都不需要了,直接用 搭配 onload 事件去執行程式碼。

假設小明是一位後端工程師,責任是去過濾這些輸入的字串,讓它不要產生 XSS 漏洞,除了過濾

設置完 name 以後跳轉過去目標網站,接著在目標網站輸入這組 payload:

// 13 + 10 = 23 字

因為 name 會共享的關係,就可以成功執行我想要的程式碼,這次只要 23 個字,成功壓在 25 個字以內。

有一個網站叫做 Tiny XSS Payloades,專門收集這些很短的 payload,裡面有更多千奇百怪的 payload,有需要的話可以參考看看,我所知道的 payload 也都是從這個網站來的。

XS leaks
上面講完了一些限制的繞過以後,我們來看看另一個主題,叫做 Cross-Site Leaks(簡寫為 XS Leaks),這個攻擊其實就是網頁上的一種 side-channel attack,有關於 side-channel attack,我之前在 CORS 完全手冊(五):跨來源的安全性問題裡面有提到過,用很知名的 Spectre 來舉例。

什麼是 side-channel attack 呢?就是你透過一些方法「間接」得知了資訊,例如說假設你面前有一個燈泡,但你的眼睛完全看不見,連光源都感受不到,你要怎麼知道這個燈泡現在是亮的還是不亮的?

有一種方式是透過「溫度」,因為燈泡如果亮著會發光,可能會產生熱能(先假設這前提為真然後忽略一些 edge case,舉例用途而已),因此你摸燈泡的時候就感覺得到熱度,這就是透過溫度間接得知了燈泡亮不亮這個資訊。

把這個概念用在網頁上的話也是類似的,透過一些方法間接得知網頁上的資訊,我們來看兩個例子。

搜尋與下載
假設現在有一個具有搜尋與下載功能的網站,可以在 query string 直接帶入想要搜尋的字串,例如說 https://example.com/download?q=example,如果資料庫裡面沒有符合的資料,就會出現一個「查無使用者」的畫面:

反之,如果有資料的話,就會直接跳出原生的檔案下載視窗,讓你直接下載相對應的檔案:

身為一個攻擊者,我們知道這個資訊以後可以做些什麼呢?

假設我有一個自己的網站,網址是 https://huli.tw,接著我在我的網站上面把剛剛的範例網站用 iframe 嵌入:

const iframe = document.createElement('iframe')
iframe.src = "https://example.com/download?q=user01"
document.body.appendChild(iframe)
這時候關鍵的來了,如果 user01 這筆資料不存在於資料庫裡面,當我試著存取 iframe.contentWindow.origin 的時候就會出錯,這是因為 huli.tw 跟 example.com 不是同源的網站,所以被瀏覽器的 Same-Origin Policy 擋了下來。

但是呢!如果 user01 這筆資料存在於資料庫裡面,不是就會直接跳出下載畫面嗎?這時候如果我去存取 iframe.contentWindow.origin,就不會出錯,因為我會拿到 null 這個結果。

所以我們可以根據存取 iframe.contentWindow.origin 的結果,得知某個關鍵字是否存在於資料庫裡面:

const iframe = document.createElement('iframe')
iframe.src = "https://example.com/download?q=user01"
document.body.appendChild(iframe)

// 先假設一秒後會載入完畢,可以做到更精確但先跳過
setTimeout(() => {
try {
iframe.contentWindow.origin
console.log('使用者存在')
} catch(err) {
console.log('使用者不存在')
}
}, 1000)
這就是 XS leaks,我們明明在 A 網站,卻可以利用一些技巧去得知 B 網站的資訊。

完整的攻擊實作會把上面的攻擊腳本延伸,例如說先測 a 再測 b 之類的,假設測到 b 是存在的,那就重複剛剛的流程去測 ba, bb…,如此一來就可以洩漏出至少一組的使用者帳號。接著只要把這個網頁的連結傳給有權限存取到 https://example.com/download 頁面而且處於登入狀態下的人,點開以後攻擊就會啟動。

雖然說聽起來前置步驟有點多,但它確實是個可行的攻擊手法。

id 的奧妙
假設現在有個標榜隱私度極高的社群網站,你沒有辦法看到你好友的好友有誰,看不到共同好友,所以你也不知道誰跟誰是朋友,只知道自己有哪些朋友。

你跟 user id 是 123 的 David 是好朋友,所以當你點進他的個人頁面:http://example.com/users/123 時,會看到一個按鈕「傳送訊息」,按鈕的 id 是 message:

而你跟 user id 是 210 的 Peter 並不是好友,所以點進他的頁面以後,會看到另一個按鈕叫做「加入好友」,id 是 add:

這聽起來都沒什麼問題,十分合理的實作,網頁上的元素有 id 再合理不過了。不過,其實這樣也會有 XS leaks 的風險。

瀏覽器有一個貼心的功能不知道大家有沒有注意過,當網址後面加上 #id 的時候,瀏覽器會自動跳到有這個 id 的段落然後把元素 focus(如果可以被 focus 的話),文章的錨點(anchor)功能就是靠這個,才能跳到特定的段落去。

因此當我連到 http://example.com/users/123#message 時,如果我跟 id 為 123 的人是好友,那頁面上就會出現傳送訊息的按鈕,瀏覽器就會跳到按鈕那邊並且把按鈕 focus。那如果我跟 123 不是好友呢?那就不會有任何事情發生。所以我們可以透過這個差異,來知道 id 123 的人是不是當前使用者的好友。

做法跟剛剛的搜尋下載很像,都是要先把目標網頁嵌入 iframe 之中,如果有這個 id 存在,那 iframe 就會 focus,而原本的 body 就會 blur:

window.onblur = () => {
console.log('是好友')
}

const iframe = document.createElement('iframe')
iframe.src = 'https://example.com/users/123#message'
document.body.appendChild(iframe)
接著把這個網頁傳給你想知道他好友狀況的人,他一打開網頁之後,你就能知道他跟 123 是不是好朋友。如果這個網站的 id 是流水號,你就可以遍歷每一個 id,得知他的好友清單裡面有誰。

以上就是兩個 XS leaks 的範例,都是透過一些瀏覽器或是 JS 的特性來達成的攻擊,如果你對這些有興趣,可以參考:XS-Leaks Wiki,裡面有更多更有趣的案例(我所知道的這些也是從這個網站來的)

如果想看 XS leaks 的實際案例,這邊有很多:Mass XS-Search using Cache Attack,而最近的這一個也很有趣:Abusing Slack’s file-sharing functionality to de-anonymise fellow workspace members

其他你可能不知道的功能
最後一個段落裡面想跟大家分享一些「你可能不知道」的功能,或更精確一點來說,是「我知道以後很驚訝」的功能,我原本沒有想到原來可以這樣做。

讀取不同 path 的 cookie
在設定 Cookie 的時候有許多參數可以設置,其中一個叫做 path,例如說我設定 cookie 的 path 是 /siteA,那當我在 /siteB 的時候,就沒辦法讀取到 /siteA 的 cookie,因為 path 不一樣,所以沒辦法拿到。

但其實不一定,如果你的網站沒有阻擋 iframe 嵌入,而且 cookie 又沒有設置 HttpOnly,就可以利用 iframe 來讀取不同 path 的 cookie:

// 假設我們在 https://example.com/siteA
const iframe = document.createElement('iframe')
iframe.src = 'https://example.com/siteB'
iframe.onload = () =>
alert(iframe.contentWindow.document.cookie)
}
document.body.appendChild(iframe)
這是因為 https://example.com/siteAhttps://example.com/siteB 雖然 path 不同,但是是同源的,因此可以直接透過 iframe 來存取同源的其他網頁的 document,就可以利用這個特性拿到 document.cookie

如果沒有支援 iframe,那其實 window.open 也可以達到一樣的效果:

const win = window.open('//example.com/siteB')
setTimeout(() => {
alert(win.document.cookie)
}, 1000)
不過要注意的是 window.open 預設會被擋住,要使用者主動允許才能開啟,或者是要使用者做操作以後執行(例如說把上面那段放在 button onclick 裡面)。

而我後來發現其實 RFC 6265 的 section 8.5: Weak Confidentiality 就有提到了(奇怪,以前讀的時候怎麼沒注意到):

Cookies do not always provide isolation by path. Although the network-level protocol does not send cookies stored for one path to another, some user agents expose cookies via non-HTTP APIs, such as HTML’s document.cookie API. Because some of these user agents (e.g., web browsers) do not isolate resources received from different paths, a resource retrieved from one path might be able to access cookies stored for another path.

讀取 PDF 內容
假設你的網站上嵌入了一個 same origin 的 pdf 檔案,像這樣:

這時候你該怎麼用 JS 去讀取這個 pdf 裡面的內容?想必答案一定就是 fetch 或是 xhr 了:

fetch("/test.pdf")
.then(res => res.blob())
.then(res => {
console.log('pdf', res)
})
那如果 fetch 沒辦法用呢?舉例來說,server 在後端擋住來自 fetch 的請求(利用 Fetch Metadata),這時候該怎麼辦呢?有沒有什麼方法可以讀到 PDF 的內容?

我以前一直覺得不可能有,直到我學到了一個隱藏的 Chrome API:

/** @override */
handleScriptingMessage(message) {
if (super.handleScriptingMessage(message)) {
return true;
}

if (this.delayScriptingMessage(message)) {
return true;
}

switch (message.data.type.toString()) {
case 'getSelectedText':
this.pluginController_.getSelectedText().then(
this.handleSelectedTextReply.bind(this));
break;
case 'getThumbnail':
const getThumbnailData =
/** @type {GetThumbnailMessageData} */ (message.data);
const page = getThumbnailData.page;
this.pluginController_.requestThumbnail(page).then(
this.sendScriptingMessage.bind(this));
break;
case 'print':
this.pluginController_.print();
break;
case 'selectAll':
this.pluginController_.selectAll();
break;
default:
return false;
}
return true;
}
從這段程式碼裡面可以看出有兩個指令 selectAll 跟 getSelectedText,前者可以全選 PDF 的內容,後者可以拿到選取的文字,因此只要結合這兩個,就能拿到 PDF 裡面的文字內容:

// HTML:

window.addEventListener('message', e => {
if (e.data.type === 'getSelectedTextReply') {
alert(e.data.selectedText)
}
})

function loaded() {
f.postMessage({type:'selectAll'}, '')
f.postMessage({type:'getSelectedText'}, '
')
}
一個簡單的 demo 網頁:https://aszx87410.github.io/demo/mw2021/05-pdf/index.html

雖然說這個技巧只能用在文字上面,但這種隱藏的功能真是令人興奮。

結語
先補充一下,上述的有些攻擊並不是所有環境都適用,例如說有些攻擊需要網站沒有擋 iframe,而拿來身份驗證的 cookie 可能也不能設定 SameSite,否則就會失效,利用 name 來傳 payload 的方法在某些瀏覽器可能也不適用,但我覺得這都不影響這些攻擊的有趣程度。

文章中有些繞過的部分並沒有寫得很完整,因為我把重點放在「找到至少一種繞過方式」,而不是「寫出所有繞過方式」,想看更完整的繞過技巧可以參考:Cheatsheet: XSS that works in 2021。

這篇文章提到的許多技巧,都是我透過打 CTF 學習而來,例如說下載檔案的 XS leaks 從 LINE CTF 2021 - Your Note,讀取不同 path 的 cookie 是從 DiceCTF 2021 - Web IDE 學到的,Chrome 的隱藏 API 則是在 zer0pts CTF 2021 - PDF Generator 學習到的技巧,透過 CTF 讓我看見了不一樣的 Web。

以上就是我近期學習到的一些與前端相關的知識,每一個都超出了我的想像,希望這篇文章有讓大家感受到我當初的驚訝,覺得:「哇,原來前端還有這些東西,我怎麼都不知道」。

原文取自:
https://blog.huli.tw/2021/10/25/learn-frontend-from-security-pov/
接觸資安才發現我不懂前端 by Huli's blog