TsukuCTF 2022 Writeup
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を生成するレシピを作りました。
あとはこのレシピを駆使してフラグを取得するだけです。
まず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ファイルごとドラッグアンドドロップすれば回路が見れます。
ICチップの中身はNANDなのでDefBom1と同じ要領で解けます。答えは2。
Discussion