これはTSG Advent Calendar 2021の2日目の記事です。
前日の記事は__dAi00さんの「時の流れの速さを感じるのはいつか」でした。
また、この記事はCTF Advent Calendar 2021の2日目の記事でもあります。前日の記事はptr-yudaiさんの「C++のpwn/revで使うSTLコンテナの構造とバグパターン一覧」でした。
(さっきCTF AdCの存在を教えてもらい、急遽登録してみたのは秘密です)
- はじめに
- TSG LIVE! とは
- TSG CTF 2021のBeginner's Web 2021について
- 参加記 環境編
- 参加記 本番編
- 反省
- 解き直し
- CTFの楽しさ 〜ミステリオタクかつCTF超初心者の視点から〜
- 蛇足 読者プレゼント(カスのweb問)
はじめに
駒場祭(東大の学祭の1つ)の企画であるTSG LIVE! 7のライブCTFに青チームの一員として参加し、椅子を温めて終わりました。
スライドのチーム紹介で、チームAはBlueなのに赤色でチームBがRedなのに青色なのは謎でしたが。
TSG LIVE! とは
私の所属サークルである東大のコンピュータ系サークルTSGが、学祭などの折に実施しているプログラミング生放送です。今回で7回目になります。すごい、歴史がある
今年も去年同様に全ての企画がフルリモートでした。CTFは2日目です。
私は3日目のライブコードゴルフ大会にも企画・実況として参加しました。こっちはまあ私の実況がグダっててアレなので見てほしくない気持ちもあるのですが、それ以上に参加者から出てきたヤバいコードがすごかったので見てほしいです。たぶんCTF好きな人だったら気に入ると思います。
「C言語でそんな曲芸みたいな短縮アリかよ!?」って実況中に叫びそうになりました。本当に感動した。
TSG CTF 2021のBeginner's Web 2021について
hakatashiさんが実況で「CTF初心者のふぁぼんが解けたから自信を持って出したら全然解かれなくてビギナー問詐欺と言われまくった」と言っていたのですが、ちょっと言い訳させてください。
あれはビギナーズラックです。
「攻撃してみろや〜」と言わんばかりのよくわからんことをしている謎コード、しかしどういう動作をしているのか分かりづらいし、絶妙にフラグを読み出せなくて困り果て、怪しそうなasync/awaitの仕様を見ようとMDNを開いたんです。
そしたら「thenという関数を含むオブジェクトをawaitしたらPromise同等にみなされる」という記述を見て、「アッこれ使えるのでは!?」となっただけです。
オブジェクトのキー、それもメソッドをこっちで指定できると面白いことが起こせるというのは、CTFを知らない人間にもかなり自明なことだと思います。valueOfとかも使えそうですね。私はCTFに無知なので知りませんが、たぶんさんざん既出。
問題と解答はこれを参照してもらうとして、もうちょい詳しく解説しておきます。
{then: ()=>"hoge"}
というオブジェクト(これはthenableです)をawaitすると、thenメソッドにresolveとrejectの2つの引数が渡されます。どっちもコールバック関数です。そして、resolveかrejectのどちらかを呼ぶまでawaitは返ってきません。しかしthenメソッドは単に"hoge"という文字列を返すだけのメソッドになっているので、awaitは永遠に返ってこない。こうして処理を途中で止めることに成功したわけです。
……私は本当にCTFを知らなくて、開発者として単にJavaScriptを多少知ってたのと、あとは運でした。はい。
参加記 環境編
全部自分で設定・構築したLinuxデスクトップを使い、エロゲソングプレイリストを流しながら参加しました。ウィンドウマネージャはXMonadです。dotfilesはこちら。ちなみにデュアルディスプレイです。XMonadはデュアルディスプレイに問題なく対応してて神。
参加記 本番編
問題はこちらを参照してもらえれば。
これはネタバレですが、椅子を温めて終わりました。
私は暗号も代数もリバースエンジニアリングも一般的な脆弱性も分からないので、問題の意味を理解できるものがWebしかありませんでした。そしてWebは1問だけ。選択肢がない状況というのは、迷いが生まれる余地がなく思考停止できるという点で割と楽だったりします。
さて、CTFにおいてsanity checkというのは簡単な「ちゃんと接続できたかな〜」みたいな感じの問題のことなのですが(以前ちょっとCTFについてググったときに知りました)、「いや唯一のWeb問がsanity checkってどういうこと?」と思いつつアクセスしてみると「SAN値チェックツール ダイスを振れます」って出てきて、「クトゥルフかぁ〜〜〜〜」となりました。クトゥルフもTRPGもなんも知らんけど……
とりあえず、動作を確認してみます。どうやら/dev/urandom
や/dev/random
を元に乱数を生成しているようです。(ちなみに、試している最中にrandomのリクエストが返ってこなくなってました。後で知ったのですが、どうやらエントロピー切れによりrandomが読み出せなくなっていたようです)
次に、サーバ側のソースが見れたので、そっちを見てみようと思います。
const stream = fs.createReadStream(`/dev/${source}`, {end: count * 4});
おー、ディレクトリトラバーサルやないかい。その特徴はもう完全にディレクトリトラバーサルやがな。sourceに"../etc/passwd"とか設定したら/etc/passwd読めるやないか。
const stream = fs.createReadStream(`/dev/${source}`, {end: count * 4}); const data = await get(stream, {encoding: 'buffer'}); return Array(count).fill().map((_, i) => ( data.readUInt32BE(i * 4) % n + 1 ));
唐突なミルクボーイはさておき(どうでもいいけど私はお笑い芸人だと金属バットが好きです)、しかし単純に読めるというわけではなさそう。読んだデータを数値の配列に変換してnで剰余取ってる感じですね。(というだけの読解に5分か10分くらいかけた。コードリーディングが亀のように遅太郎と申します……)
さて、フラグを得る方法は……
const flag = process.env.FLAG;
なるほど、環境変数ですか。
app.post('/', async (req) => { const {source, message} = req.body; if (message.length >= 7) { return 'Too long!'; } const [count, n] = message.split('d').map(Number); const numbers = await getRandoms(n, count, source); const sum = numbers.reduce((a, b) => a + b); if (sum === 77777) { return `Jackpot!!! ${flag}`; } return numbers.join(' + ') + ' = ' + sum; });
お、乱数の合計が77777になるとflagが開示されるようです。さっき乱数の元ファイルを自由に指定できるのが明らかになったので、いい感じになるような元ファイルとダイスロール指定を組み合わせれば解けるでしょう。
とりあえず、冒頭4文字が判明してる/etc/passwdを指定して、uint32として読み込んだ値を調べて、その値のmodを取ったときに77776になるような法を素因数分解使いつつ調べて……よしできた、942941だ。さあ実行するぞ!
……と思っていた時期が私にもありました。
// messageは2d6などのこと if (message.length >= 7) { return 'Too long!'; }
はい、modの法もロール回数も、合計で5文字以下と指定されています。さっき求めた法は6桁です。悪いことはできないもんですね。
なお、これは終わった後の感想戦でTSG外のCTFerに言われたことなのですが、77777d1とか指定できちゃうと確定で77777にできます。確かに。コロンブスの卵だ……
振り出しに戻る。
冷静に考えると、たとえファイルを指定できたとしても、99d999の最大値が98901なのに対して77777を狙うのは厳しい気がします。/dev/zero
は永遠に0(ダイスだと1)ばかり吐き出すし、逆に1を延々と吐き出すデバイスファイルは存在しないですし……
大量のファイルを総当たりすれば1つくらいは当たるかもしれませんが、そもそも私はサーバにあるファイルのリストを持っていませんし、手の出しようがありません。何より、hakatashi先生ならそんなエスパー+DOS一歩手前の方法を想定解にしない、という信頼がありました。
まあ、今回に関しては単にDOSはよろしくないという話ですけどね。一般的な話として、想定解でなかろうがフラグ取ってナンボのCTFにおいて力技を試しもせずに諦めてしまうのは、私のCTFプレイヤーとしての重大な弱点とも言えそうです。実装の遅さとそれに起因・関連する諦めの早さはCTFに限らないですが……
このあたりで少し焦り始めます。
というわけで、文字列チェックをすり抜ける方法を考えてみます。
まず、bodyに任意JSONデータを入れられるので、{message:{length: 5}}
みたいにしてみるとどうなるでしょう?
結論から言うと、どう足掻いてもsplitメソッドを呼べず死にます。JSONでメソッドは作れないですからね。というか作れたらセキュリティがヤバいだろ。
次に、16進数表記などを使って範囲を拡張できないか試してみたのですが、16進数なら先頭に0xをつける必要があり、よけい指定できる範囲が狭くなりました。ダメだこりゃ。
万事休す。
光陰矢のごとし。
打てる手はなく、app.jsを眺めては攻撃の糸口が見つからないとため息をつくだけの時間。チームメイトのQueryさんとmikanamiさんがどんどん問題を解いてチームの得点に貢献しているのをただ傍観するだけ。
しかし、終了15分前くらいで、ようやく重要な気付きを得ました。
これ、複数の元で試せば中国剰余定理によって元の値、そしてファイルを復元できるのでは? つまりサーバの任意ファイルを読み出せるんじゃ?
たとえば99dxxxとすれば冒頭396バイトを読み出せる計算です。これだと3桁のmodしか取れないですが、4つの法を試せば最大公約数がを超え、元の値を一意に定めることができます。32ビット符号なし整数は最大がなので。
中国剰余定理は極めて有名な典型問題なので、ソルバくらいどっかに落ちてるでしょ。
で、何のファイルを読めというんですか? なんかflag.txtというファイルがソースに混ざっていたのですが、こいつを読めばいいんでしょうか? でも本番環境でこのアプリのソースがどこに配置してるかなんて私には分かりませんし……
ちなみに、後から分かったことですが、このファイルはミスで混入してしまっただけで、本番環境のflag.txtにも正しいフラグは書かれていなかったらしいですね。
タイムアップ。
0完太陽。
椅子を温めただけ。
反省
後でCTFの放送部分を見たのですが、実況の人(特にJP3BGYさん)が中国剰余定理パートについて「(Cryptoが得意で数学に詳しい)Queryくんに計算してもらって! 情報共有して!」と叫んでて面白かったです。
今回に関しては、まあ復元しようにも復元すべきファイルが分からなかったので、相談したところで変わらなかったわけですが。しかし、諦めが早すぎる上に1人で抱え込みがちなのはかなり根深い問題で、克服すべき1つの壁なのだと思います。
道筋が見えて自分でもやればできると確信してしまうと(実際時間があれば可能なわけですが)、間に合うか怪しく自分より詳しい人がいるというような状況ですら、相談するという選択肢が見えなくなっちゃうんですよね……
解き直し
放送中、最後のインタビューでちらっと触れられてたのですが、プロセス自身の環境変数(ただし初期のものだけ、内部で再設定したものは別)が格納されてる/proc/self/environ
というデバイスファイルを読み出せばよかったらしいです。(余談ですが、/proc
はたぶんBSDには存在しないGNU/Linux限定のディレクトリだと思います)
どうやら本当にあと一歩わずかに及ばずというところだったらしく、しかし知らないものは知らないので、悔しいけどどうしようもなかったかな……と思っています。/proc
にもデバイスファイルがあるということを完全に失念していました……そもそもの話として環境変数を読みにいくという発想もなかったのでオワリです。
中国剰余定理のソルバはこのサイトのものをお借り(コピペ)して、実際に977と983と991と997を法として試してみました。/proc/self/environ
は環境変数をnull文字区切りで出力するようなのですが、まあそんなのはコード見れば分かるか。62d977とか62d997とか送りつければいい、というわけです。
# ↑上のサイトからcrtメソッドをコピペしてくる。ただし1箇所typoがあったので、そこだけは直す。 # ↓ここから私のコード r983 = "816 + 913 + 485 + 924 + 849 + 840 + 545 + 366 + 816 + 968 + 855 + 761 + 674 + 610 + 551 + 212 + 50 + 296 + 7 + 288 + 803 + 507 + 351 + 271 + 670 + 377 + 351 + 271 + 653 + 693 + 953 + 578 + 978 + 256 + 892 + 377 + 30 + 273 + 852 + 743 + 956 + 362 + 354 + 410 + 750 + 549 + 81 + 705 + 107 + 236 + 401 + 873 + 404 + 330 + 564 + 369 + 770 + 251 + 390 + 864 + 64 + 552".split(" + ") r991 = "411 + 498 + 386 + 933 + 151 + 929 + 10 + 586 + 401 + 870 + 904 + 841 + 388 + 78 + 527 + 895 + 487 + 168 + 244 + 242 + 919 + 522 + 532 + 305 + 895 + 306 + 532 + 305 + 878 + 591 + 699 + 49 + 274 + 955 + 905 + 306 + 453 + 38 + 846 + 368 + 3 + 344 + 971 + 188 + 447 + 987 + 296 + 437 + 898 + 800 + 943 + 624 + 182 + 776 + 924 + 278 + 21 + 657 + 431 + 217 + 648 + 827".split(" + ") r997 = "980 + 269 + 910 + 483 + 508 + 159 + 680 + 224 + 757 + 925 + 626 + 686 + 850 + 47 + 870 + 693 + 416 + 694 + 99 + 488 + 730 + 578 + 285 + 643 + 182 + 799 + 285 + 643 + 165 + 995 + 431 + 321 + 166 + 426 + 609 + 799 + 781 + 64 + 125 + 349 + 246 + 669 + 376 + 648 + 343 + 571 + 924 + 679 + 326 + 542 + 258 + 210 + 642 + 687 + 766 + 901 + 459 + 150 + 714 + 415 + 601 + 362".split(" + ") r977 = "828 + 652 + 143 + 780 + 732 + 450 + 46 + 717 + 737 + 786 + 623 + 432 + 147 + 566 + 396 + 829 + 283 + 283 + 146 + 419 + 749 + 970 + 729 + 951 + 22 + 402 + 729 + 951 + 5 + 392 + 568 + 582 + 675 + 548 + 362 + 402 + 610 + 168 + 953 + 135 + 107 + 193 + 870 + 305 + 826 + 712 + 56 + 461 + 915 + 598 + 119 + 188 + 299 + 628 + 952 + 894 + 787 + 768 + 174 + 638 + 184 + 161".split(" + ") a = r983.zip(r991,r997,r977).map do |e| crt(e.map{_1.to_i-1},[983,991,997,977])[0] end puts a.pack("N*").split("\x00")
NODE_VERSION=16.13.0 HOSTNAME=ba00ae60e06f YARN_VERSION=1.22.15 SHLVL=2 HOME=/root PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PWD=/app FLAG=TSGLIVE{Ph'nglui_mglw'nafh_Cthulhu_R'lyeh_wgah'nagl_fhtagn:_SANITY_CHECK_FAILED!!!!}
CTFの楽しさ 〜ミステリオタクかつCTF超初心者の視点から〜
良いCTFの問題を解くのには本格ミステリの「読者への挑戦」を考えるのと似た楽しさがあるんじゃないか——
たった数問しか解いてない人間がこんな適当なことを言うのはアレですが、なんとなくそう思っています。ちなみに私はミステリ、特に本格推理の大ファンです。
「お前はCTFのことを何も分かってない、単なるその作問者の作風を共通の性質だと思い込む低能がよ……」とか言われるのがマジで怖いので全力で予防線を張っておきますが、「フラグを読み出す」という一見不可能な問題を与えられるCTFには明らかに不可能状況のハウダニットの要素があると思います。さらに、エスパーせずに解けるようになっている問題なら、それはすなわち「読者への挑戦」と同じようなものではないでしょうか。
もっとも、ここまで強く「本格ミステリっぽくて楽しい」と思えたのは初めにhakatashi先生の問題に触れられたからで、そういう意味で私はとても幸運だったのでしょう。
(中2の正月にksnctfに手をつけて、Crawling Chaosは解いたもののCTFが何か全くわからずやめてしまった過去があります)
「この変数をセットした上でこの関数を実行すればフラグが出力されるが、実行するとそもそも変数が書き変わってしまう。どうする?」と「このナイフで刺せば殺人現場を再現できるが、そのナイフが置かれた部屋は密室で、鍵の番号を知っている人には全員アリバイがあった。犯人はどうやって被害者を殺した?」ってそんな変わらないと思うんですよ。
あと、ミステリの定石「自明だと思い込んでいる前提を疑う」はCTF解くときでもけっこう強い気がします。
まあ、ミステリ・CTF比較論はそのうち私がCTFに慣れて「web問ってだいたいこんな感じだなあ」というのが分かった頃にちゃんと論じることにします。結局「hakatashi先生の問題が特殊なだけで、一般的にはいうてそんなミステリっぽくないな……」って結論になっちゃうかもしれませんが。
蛇足 読者プレゼント(カスのweb問)
TSG CTFの数日後にCTF知識ゼロのオタクが作ったマジのゴミカスweb問を進呈します。「それなりに考えて作ったけど、どうせ典型なんやろうなあ。どんな典型テクニックがあるかすら知らんけど……」と思って雑に投げたら実際典型だったようで、秒で解かれてウケてました。あまりに典型すぎたのか「無から典型を錬成できるの、才能ありそう」などとフォローが飛んでくる始末。
ちなみに、さっきも触れた話ですが、こういうの考えるのってミステリのトリックを考えるのと同じような楽しさがあるし、なんならミステリのトリックを考えるときの思考法をまんま流用できちゃうんですよね。核心となるトリックを考える、謎の焦点をズラす、ミスリードを狙う、など。
なので、仮に私に才能があったとしても、それはたぶんCTFじゃなくてミステリ書きの才能なんじゃないでしょうか。知らんけど。(ネタ帳に溜まってるカスのミステリのネタから目を逸らしつつ)
// サーバのソース app.js const fastify = require('fastify')({ logger: true }); fastify.register(require('fastify-formbody')); const routes = { flag: ()=>'FLAG{hogefugapiyo}', count: data=>data.length }; fastify.post('/', (request, reply)=>{ const action = request.body.action; if (action.includes('f')) { return { message: "Don't use the F-word!!!" }; } const res = routes[action](request.body.data); return { result: res }; }); const start = async () => { try { await fastify.listen(3000); } catch (err) { fastify.log.error(err); process.exit(1); } } start();
CTF Advent Calendar、次回は7日のFurutsukiさんです。
TSG Advent Calendar、明日……は今のところ空き枠で誰も埋めてないのですが、次の記事は5日のhideo54さんによる「なんかかく」です。お楽しみに!