さる5月13日・14日に東京大学の学園祭である五月祭が開催されました。
音声がいろいろ聞こえづらくて申し訳ないのですが、アーカイブもあります。
どうして私が作問で!?
ここ数年のライブCTFではwebの作問をhakatashiさんが担当していたのですが、今回は忙しく作問できないという申し出がありました。そもそもhakatashiさんは卒業生ですからね、学祭であまり頼るのも良くないわけですよ。
……え、webの作問者いなくね? 誰がやるの?
そう思っていたところ、なんか役目が私のところに回ってきました。えっ私そんなCTFできないですけど、いいんですか? というかwebのプレイヤーいない気がしますけど大丈夫です?
(結論から言えば、後からbitmathさんが追加で参加することになったので大丈夫でした)
まあ、こないだの部内イベントで作問したので、一応作問経験者ではあります。しかし、TSGのライブCTFといえば短時間勝負にふさわしい良問が出るという評判があります(ふぁぼん調べ)。これ私がゴミ問題出したらボッコボコに叩かれるのでは?
そう思って過去の問題を眺めたところ、webは瞬殺問題しか出ていない回もあったので、これなら許されるか〜と開き直ることにしました。最悪マジで思いつかなかったら思考停止SQLインジェクションでも出そう。
もっと適任の人がいるなら全面的に任せたのですが、いないなら仕方ありません。腹をくくります。
完成した問題
問題とソルバは上のリポジトリを参照してください。webはprofile viewerの1問しかなくて本当に申し訳ないのですが……
web問(profile viewer)の配布ファイルはこちらです。
以下、ネタバレゾーン。
作問者writeup
実況のときに使った解説スライドです。我ながらそこそこ分かりやすいし、もうwriteupこれでいいんじゃないかな……
気を取り直して、writeupを書きます。
とりあえず、短いのでserver.js
の全コードを載せておきますね。どうも私、CTFの作問のことを「いかに簡潔かつ自然なコードで面白い問題を作れるか」の勝負だと思ってる節が……
const express = require('express') const session = require('express-session') const app = express() app.use(express.urlencoded({ extended: true })) app.use(express.static('public')) app.use(session({ resave: false, saveUninitialized: false, secret: require('node:crypto').randomBytes(32).toString('base64') })) // this is flag const secretFlag = process.env.FLAG ?? 'TSGLIVE{DUMMY}' function getUsers() { return { alice: { name: 'Alice Anderson' }, bob: { name: 'Bob Baker' }, carol: { name: 'Carol Carlson' }, dave: { name: 'Dave Dixon' }, eve: { name: 'Eve Eaton' } } } app.get('/profile', (req, res) => { const profile = JSON.parse(req.session.data ?? '{ "profile": {} }').profile res.json({ secret: profile[secretFlag], name: profile.name }) }) app.post('/profile', (req, res) => { const name = req.body.name if (name.length > 5) { res.send('codename too long') return } const profile = getUsers()[name] if (profile == null || typeof profile.name !== 'string') { res.send('Unknown codename') return } profile[secretFlag] = 'personal data' req.session.data = JSON.stringify({ profile }) res.send('ok') }) app.listen(3456, () => { console.log(`Example app listening on port 3456`) })
フラグは環境変数から読み込まれています。おまけに使われている箇所が2つだけ、両方ともキー文字列としての利用です。パストラバーサルとかコマンドインジェクションといった脆弱性もなく、/proc/self/environ
を読めそうな雰囲気もありません。安全でないデシリアライゼーションからのRCEも使えなさそうです。
そこでprofile[secretFlag]
という部分を眺めていると、profile
をundefined
やnull
にできればエラー経由でフラグが降ってきそうな気がしてきます。たぶんTypeError: Cannot read properties of undefined (reading ‘TSGLIVE{flag}')
みたいな感じですね。NODE_ENVがproductionに設定されてないので(配布されたcompose.yamlを見てみましょう)、expressがエラーの詳細をそのまま表示する設定になってます。
さて、次はprofile
をいかにundefined
やnull
にするか、それが問題になってきます。単純にnameを存在しないキーにすると、型チェックに引っ掛かってしまいます。ここで注目すべきは、セッションに保存される情報がJSON文字列の形式ということです。POSTのエンドポイントではJSON.stringify
を通してデータを保存し、GETの方のエンドポイントではJSON.parse
で元データを復元しています。
JSON.stringify
の仕様を調べると、関数やundefinedやsymbolを単純にオミットしてしまうようです。そこで、POSTの/profile
の方の処理を見てみます。
// getUsersは連想配列を返す const profile = getUsers()[name] if (profile == null || typeof profile.name !== 'string') { res.send('Unknown codename') return } profile[secretFlag] = 'personal data' req.session.data = JSON.stringify({ profile })
ここでnameに'constructor'
や'toString'
などの文字列を入れると、profileは関数になります。関数はnullでもundefinedでもなく、nameプロパティが文字列なので、型チェックを通過してしまいます。そして関数はJSON.stringify
で消え去るので、セッションのdataプロパティには'{}'
という空のデータが保存されるというわけです。
const profile = JSON.parse(req.session.data ?? '{ "profile": {} }').profile res.json({ secret: profile[secretFlag], name: profile.name })
その後、GETで/profile
にアクセスすると何が起きるでしょうか。profileが無事undefined
になり、TypeErrorのメッセージが表示されます。そこに書かれたフラグを取れば問題解決です。
……嘘です。nameには文字数制限があり、5文字以下でないとバリデーションに引っ掛かります。使えるObject.prototype
のメソッドは最短でvalueOf
の7文字です。
// POSTの方 const name = req.body.name if (name.length > 5) { res.send('codename too long') return }
どうしたものでしょうか。
ここで、問題と全く関係なさそうに見える上の方のコードに目を向けてみましょう。
app.use(express.urlencoded({ extended: true }))
このextended: true
って何でしょう?
調べてみると、application/x-www-form-urlencoded
で配列やネストしたオブジェクトを受け取れる独自拡張をオンにしているようです。つまり、nameに入るのは文字列とは限らないわけですね。ここでJavaScript問の頻出テク、「文字列ではなく配列を投げてみる」を発動してみましょう。nameを['constructor']
にすると……?
まず、文字列の長さチェックを通過します。長さ1の配列なのでlengthも1です。当然ですね。
次に、const profile = getUsers()[name]
の部分です。ここでnameはオブジェクトのキーとして使われているので、暗黙の型変換で文字列になります。そして配列から文字列への変換はコンマでjoinした文字列なので、['constructor']
はそのまま'constructor'
になります。後は上で書いた通り、JSON.stringify
でprofileが消えおおせ、セッションに空オブジェクト(のJSON文字列)がセットされ、次にGETリクエストを投げるとフラグを含むエラーページが表示されます。
まとめ
全体として、「リクエストパラメータが文字列とは限らない」「JavaScriptの暗黙の型変換は怖い」「JSON.stringify
の普段あまり気にしない仕様を利用する」「オブジェクトを連想配列として使う際のプロトタイプチェーンの罠を見抜く」「関数にはnameプロパティがある」「エラーページから情報を得る」という感じの問題でした。1つ1つはCTFとして割と頻出のものだと思います。
expressはNODE_ENV環境変数をproductionにすればエラーの詳細なログを表示しなくなるのですが、その一手間をサボった結果、攻撃者に有用な取っかかりを与えてしまいました。みなさん本番環境でちゃんとエラーの詳細を表示しないようにしていますか? NODE_ENVとかRAILS_ENVとかをちゃんとproductionにしてますか?
また、この問題に登場したリクエストボディのパーサの独自拡張は決してNode.jsのフレームワークに特有のものではありません。というかRailsの方が先な気がします。「文字列に違いない」という予断は禁物、想定外の値が入ってくることを前提にコードを書きましょう。
当日、first bloodの人(st98さん、webが鬼のように強いCTFer)から「こういうシンプルな問題は好き」とリプライをもらい感激しました。少なくともカスの問題を出すのは避けられたっぽい。数日間発狂しつつ足りない頭を絞って考えた甲斐がありました。
フラグ
TSGLIVE{7w1n574r_cyc10n3_run4w4y}
ツインスター・サイクロン・ランナウェイ。こないだ3巻が出ました。
百合です。SFです。宇宙です。読んでください。
作問記
今回の作問では、とにかく「フェアであること」を最優先としました。ここでいう「フェア」というのは本格ミステリにおける用語です。この言葉についてちゃんと説明すると長ったらしいミステリ談義が始まってしまいますが、大雑把に言うと「そんなの分かるわけないじゃん!」と言われてしまったらダメということです。奇想天外な発想は許されますが、読者(プレイヤー)が知らない知識や情報を使ったりするのは許されません。
そして、今回は想定知識レベルを「多少CTFに慣れたweb開発者」に設定しました。普通のweb開発者が知っている、あるいはコードを元に調べれば分かる知識は使っていいことにします。
もちろん、本格ミステリ的フェアネスがCTFの作問全般で重視されるべきだとは全く思っていません。今回はめちゃくちゃ短時間のCTFという特殊事情があり、その中で面白いと思ってもらえるweb問題を出そうとしたらこうなっただけです。
さて、今まで各種CTFの過去問を調べつつ発狂しながら2問くらい作問した結果、作問に使えるギミックみたいなもののストックが多少充実してきました。プロトタイプ汚染とかSQLインジェクションといった攻撃手法のレイヤの話もありますし、「エラーからフラグを入手する」とか「文字列のところに配列を投げる」とか「フィルタをバイパス」とかの細かい手法レベルの話もあります。
いろいろ調べていくうちに、JSON.stringify
の仕様がちょっと非直感的だということに気付きました。今回の関数がオミットされる話は意外と盲点ですよね。今回の問題では使いませんでしたが、JSON.stringify(undefined)
がundefined
になるという信じられないびっくり仕様もあります。いやまあ確かにJSON.stringify(undefined)
の返り値は何が適切か考えると全部ダメだからundefined
にするしかないのは分かりますが……stringifyじゃなくてstringify_but_maybe_undefinedに改名しろ。
そこからは問題設定をいろいろいじりまくって、頭の中でCTF向けギミックをパズルみたいにガチャガチャ組み立てました。あっちをいじればこっちが立たず、さらにこっちをいじってそっちが壊れる。もちろんほとんどの試行は失敗するんですが、そのうち奇跡的に全てが噛み合っていい感じになる時が訪れます。訪れないこともあります。今回はラッキーだったので訪れました。
簡潔かつ自然なソースコードで一見すると穴なんてないように見えますが、複数のちょっとした仕様の盲点がきれいに揃ってフラグがゲットできます。しかも一応理詰めでちゃんと解けるはずです。本格ミステリ短編集の1番目に載ってそうな不可能状況のハウダニットものみたいな感じですね。パズル問としての完成度・芸術点にはそこそこ自信があるし、私は雑魚CTFerなのでこれを超えるものを今後作れる気がしません。正直CTFer諦めてミステリ作家目指した方がいい気がする!
PoCができたのであとはその他の部分を実装するわけですが、ここでも「フェアネス」に留意しました。たとえばexpressのbody parserはデフォルトで配列やネストの独自拡張がオンになっています。なのでextended: true
はデフォルト値を明記しているだけで本来不要ですし、消した方が難しくなったでしょう。しかし、ここを抜くと「expressの細かい仕様なんて知らねえよ!」となってしまう気がしたので、あえて付けました。なんというか、そこはあまり本質的な難しさだと思えなかったので……(手元でサーバを動かすと「デフォルトで独自拡張がオンだよ、注意してね」というログが流れるので、そこから気付くルートも一応あるにはあります)
難易度設定について
できた問題をhakatashiさんにもレビューしてもらい(さすがにレビューもらわずに出す度胸はないです)、「ライブCTF基準ではけっこう難しいのでは」みたいなコメントをもらいました。確かにギミックが何段階かあるのでちょっと難しい気もしますが、でも気付いてしまえば一瞬なんですよね。こういうパズル問題を出すと、地道な作業を要求する他ジャンルとのバランスが問題になりかねません。
めちゃくちゃ悩んだのですが、最終的にmedで400点にしました。これを解ける人なら400点あげてもいいでしょ、みたいな感じです。web問題を1問しか作れなかったお詫び……と言ってはアレですが……
しかし本番ではbitmathさんが20分くらいで解いてしまいました。bitmathさんはwebが得意と聞いていたので(というかたぶん作問者の私よりwebできると思います)、「解いてくれると嬉しいなあ〜」とは思ってました。でもさすがにちょっと速すぎない???? 私がプレイヤーだったら20分で解ける自信全くないです。1時間あっても微妙なライン。
一瞬「20分で解ける問題に400点ってちょっと高すぎたかなあ」とも思ったのですが、冷静に考えてこれを20分で解けるなら400点あげて全然いいんじゃないでしょうか。……と言いつつ、やはり他の分野とのバランスが……(以下、無限ループ)
パズル問って作るときめちゃくちゃ楽しいんですけど、点数設定が本当に難しいですね。痛感しました。
まとめ
次に作問するなら2問以上作ります。はい。
というかそろそろプレイヤーとして「永遠の0点」の汚名を返上したい……でも次もワンチャン作問に回される可能性が……
あと、これは関係ない話なのですが、今ちょうど部内のCTF初心者講義でwebを教えてます。なぜ私が?