[TsukuCTF 2022] My own writeup

Posted on Oct 24, 2022

TsukuCTF 2022が10/22 12:20 - 10/23 18:00(JST)の日程で開催されました. webを解いていたので, そのwriteupを以下に記します.

  • [web] bughunter(86 solves)
  • [web] viewer(8 solves, not solved)

[web] bughunter(86 solves)

問題文

天才ハッカーのつくし君は、どんなサイトの脆弱性でも見つけることができます。 あなたも彼のようにこのサイトの脆弱性を見つけることができますか? 見つけたら私たちに報告してください。 ※ディレクトリの総当たりなどは禁止されています。本問題の解決には、多数のリクエストは不要です。

http://133.130.103.51:31415

解法

問題にRFC9116がタグ付けされています. RFC9116で検索すると, こちらのページが見つかります.

RFC9116は、組織が脆弱性の開示方法を説明し、セキュリティ研究者などが発見した脆弱性を報告しやすくするために定義されたものです。この仕組みは単純で、必要な情報を記載した「security.txt」ファイルをWebサイトの「/.well-known/」パスの下に公開することを要求しています。「security.txt」には次の項目が記述されます。

実際に/.well-known/security.txtをGETすると, flagが書かれていました.

$ curl "http://133.130.103.51:31415/.well-known/security.txt"
Contact: TsukuCTF22{y0u_c4n_c47ch_bu65_4ll_y34r_r0und_1n_7h3_1n73rn37}
Expires: 2022-10-20T15:00:00.000Z
Preferred-Languages: ja, en%     

[web] viewer(8 solves, not solved)

問題文

Writeups for TsukuCTF21 have been published. Check them out if you’d like!

URL

解法

解けませんでした. 配布コードのapp.pyは以下のようになっています.

from flask import (
    Flask,
    abort,
    make_response,
    render_template,
    request,
    redirect
)
import redis
import pycurl
from io import BytesIO
import traceback
import uuid
import json

app = Flask(__name__)

# initialization
redis = redis.Redis(host='redis', port=6379, db=0)
flag = "TsukuCTF22{dummy flag}" # the flag is replaced a real flag in a production environment.
id = str(uuid.uuid4())
redis.set(id, json.dumps({"id": id, "name": flag}))

# only 'http' and 'https' should have been allowed, right?
# ref: https://everything.curl.dev/cmdline/urls/scheme#supported-schemes
blacklist_of_scheme = ['dict', 'file', 'ftp', 'gopher', 'imap', 'ldap', 'mqtt', 'pop3', 'rtmp', 'rtsp', 'scp', 'smb', 'smtp', 'telnet']

def url_sanitizer(uri: str) -> str:
    if len(uri) == 0 or any([scheme in uri for scheme in blacklist_of_scheme]):
        return "https://fans.sechack365.com"
    return uri

# a response is also sanitized just in case because the flag is super sensitive information.
blacklist_in_response = ['TsukuCTF22']

def response_sanitizer(body: str) -> str:
    if any([scheme in body for scheme in blacklist_in_response]):
        return "SANITIZED: a sensitive data is included!"
    return body

@app.route("/<path:path>")
def missing_handler(path):
    abort(404, "ページが見つかりません")

@app.route("/", methods=["GET", "POST"])
def route_index():
    session_id = request.cookies.get('__SESSION_ID')
    name = None
    if session_id is not None:
        res= redis.get(session_id)
        if res is not None:
            user = json.loads(res)
            print(f"user: {user}")
            name = user["name"]
            if name is not None and "TsukuCTF22{" in name:
                name = "tsukushi"
    else:
        return redirect('/register')

    if request.method == "POST":
        url = url_sanitizer(request.form.get("url"))

        buf = BytesIO()
        try:
            c = pycurl.Curl()
            c.setopt(c.URL, url)
            c.setopt(c.WRITEDATA, buf)
            c.perform()
            c.close()
    
            body = buf.getvalue().decode('utf-8')
            print(body)
        except Exception as e:
            print(e)
            traceback.print_exc()
            abort("error occurs")
        return render_template("index.html", url=url, data=response_sanitizer(body), name=name)
    return render_template("index.html", data=None, name=name)

@app.route("/register", methods=["GET"])
def register():
    return render_template("register.html")
    

@app.route("/register", methods=["POST"])
def register_post():
    name = request.form.get("name")
    redis.set(id, json.dumps({"id": str(uuid.uuid4()), "name": name}))
    redis.expire(id, 100)
    
    resp = make_response(redirect('/'))
    resp.set_cookie('__SESSION_ID', id)
    return resp

@app.route("/logout", methods=["GET"])
def logout():
    resp = make_response(redirect('/'))
    resp.set_cookie('__SESSION_ID', '', expires=0)
    return resp

if __name__ == "__main__":
    app.run(debug=False, host="0.0.0.0", port=31555)

以下の部分を見ると, インラインメモリデータストアであるredisにflagを格納していることがわかります.

# initialization
redis = redis.Redis(host='redis', port=6379, db=0)
flag = "TsukuCTF22{dummy flag}" # the flag is replaced a real flag in a production environment.
id = str(uuid.uuid4())
redis.set(id, json.dumps({"id": id, "name": flag}))

また, 以下の部分を見ると, ユーザーから任意のURLを受け取ってそれを元にpycurlを用いてリクエストを送信し, レスポンスをhtmlにレンダリングすることがわかります.

if request.method == "POST":
    url = url_sanitizer(request.form.get("url"))

    buf = BytesIO()
    try:
        c = pycurl.Curl()
        c.setopt(c.URL, url)
        c.setopt(c.WRITEDATA, buf)
        c.perform()
        c.close()

        body = buf.getvalue().decode('utf-8')
        print(body)
    except Exception as e:
        print(e)
        traceback.print_exc()
        abort("error occurs")
    return render_template("index.html", url=url, data=response_sanitizer(body), name=name)

これらの情報からSSRFの脆弱性を用いて, redisからflagを取得するのだろうという予想を立てることができます.

しかし, ユーザーがPOSTするURLは以下のようにサニタイズされており, プロトコルがhttpまたはhttpsしか使用できないようです.

def url_sanitizer(uri: str) -> str:
    if len(uri) == 0 or any([scheme in uri for scheme in blacklist_of_scheme]):
        return "https://fans.sechack365.com"
    return uri

調べると, RedisクライアントはRESPプロトコルを用いてサーバを通信するようです. そして, curlがサポートしているプロトコルではtelnetやgopherを用いて通信ができるようですが, これらのプロトコルはサニタイズによって弾かれます. しかし, httpでも通信ができるという旨の記事を発見したため, httpで通信する方向性で頑張っていましたが, 解けませんでした.

実際に解かれている方のwriteupを見ると, どうやらredisの最近のバージョンではhttpは使用できないようです(参考).

代わりに, URLのサニタイズ部分で小文字のチェックはしていますが, 大文字をまじえた文字列のチェックはしていないのでそれを利用してgopherdictなどのプロトコルで通信し, flagをredisから抜き出すようです(詳細は割愛).

感想

OSINT中心のCTFでしたが, web問題もしっかり難易度が高くてとても勉強になりました. viewerのような歯応えのある問題をしっかり通せるように頑張りたいです.