CTF問題をCloud Runで動かす - gVisorとio_uring

以前にakictfという常設CTFを公開していたのですが、閉じることになったきっかけはメンテナンスが面倒になったからでした。当時は2013年、Dockerも出始めた頃で当然使っておらず、LXC上で問題を管理していました。さくらのVPS上で動かしていて(後にさくらのクラウドに移行)、問題も手作業でデプロイしていた、素朴な時代でした。

10年前だったからよかったものの、今ではどうでしょう。 コンテナイメージをそのままデプロイできる、アクセスがあったときだけコンテナを動かしていて欲しい、スケールしやすい、メンテナンスもほぼ不要。それくらい簡単なものが欲しい、そんなものがどこかにないでしょうか。

Cloud Run

cloud.google.com

そこでCloud Runです。コンテナイメージがあればとにかく動き、ある程度のスケールも勝手にやってくれる、便利なサービスです。動かすアプリケーションがHTTPで通信するのであればすぐさま使えます。

Cloud Runにデプロイする際に注意すべきポイントとしては、アプリケーション上で任意コードを実行できるのであれば、Cloud Runの最大同時リクエスト数は1にする必要があることです。そうしないと、設定上書き込みができるファイルを削除されたり、プロセス自体を上書きしてしまうことで動作を変えたりと他の参加者への妨害ができてしまいます。

Speedrun CTF

2023/12/5にオフラインイベントとして、Flatt Security Speedrun CTF #2というCTFを開催し、その中ではCloud Runに問題をデプロイする形で提供しました。

github.com

#1と比較して難易度を下げての開催ではあったのですが、80分の競技中には全完者はおらず、そのひとつの原因にある問題に想定してなかったトラブルが発生したということがありました。

問題4: semgrep

4問目のsemgrepは、入力されたJavaScriptのコードをsemgrepの独自ルール(例えば、evalやrequireなどが使えないといったもの)の上で検証し、その上ですべてのルールに通るものを実行できるという問題でした。 想定解は以下にあるように、最終的にsemgrepでの検証時と実行時のコードでズレを発生させることで、semgrepでは正しくJavaScriptとしてパースできないが実行時には動くといったコードにすることでルール自体を無視できるというものです。*1

ここで発生したトラブルというのは、競技中に参加者からローカルでは動いたが、リモートでは503を返して動かない解法があると問い合わせがあったというものでした。503といえば、初手でサーバの負荷を疑うところですが、ひとまずメモリサイズを大きくしてみても状況は変わりません。 Cloud Run のトラブルシューティング  |  Cloud Run のドキュメント  |  Google Cloud には、メモリ不足やアプリケーション側のリクエストのタイムアウト、単にリクエスト数を捌けないなどの可能性があると書かれています。 なぜかレスポンスを返せずに打ち切られているようでしたが、競技中はその原因がわからず、想定解は変わらずリモートに対しても動いており、参加者からも別の方法で解けたという報告があったため対応できず、そのままとなりました。

競技終了後に公開された参加者のwriteupでは、以下のコードがリモート上で動かないと書かれていました。

nanimokangaeteinai.hateblo.jp

(globalThis[String.fromCharCode(66, 117, 110)].file(String.fromCharCode(47, 102, 108, 97, 103))).text()

これはawait Bun.file('/flag').text()相当のコードですが、これだけをそのままCloud Run上で動かしてみると503を返します。 想定解ではBun.spawnSyncを使ってcat /flagのコマンドを実行する形にしていたことからCloud Run上でも動いていたのですが、ファイルをシンプルに読み出すだけで何が変わるのでしょうか。

Cloud Run gen1の制約

Cloud Runの設定項目のひとつに実行環境というものがあり、gen1とgen2を選択することができます。 gen1では コンテナ ランタイムの契約  |  Cloud Run のドキュメント  |  Google Cloud にあるように、gVisor上で動くため一部のシステムコールは使えなかったり、部分的に実装されていないものがあります。gen2ではそのような制約はありません。

多くのウェブアプリケーションでは、gen1を選択してもgVisorのシステムコールの制限に困ることはほぼないといってよいでしょう。 ただ、 Linux/amd64 - gVisorシステムコールがfull supportだったからといって、細かな挙動は異なることに注意する必要があります。 例えば、実行ファイルに対して与えるsetuid bitはサポートされていません。また、execveを実行する際には実行ファイルにはexecuteパーミッションのみが与えられていた場合でも通常は動きますが、gVisor上では当然正しく動きません。

こういったことは想像できるので、CTFの問題を動かす際にはgen2を選択するのがよいはずです。 ただ、gen1では最小メモリは128MiBですが、gen2では512MiBなので、gen1を使ってメモリを抑えられるなら若干安く済ませられるメリットがあり、問題のデプロイ時にはgen1を選択していたのがこれが悪さをしていました。

BunとgVisor

まずは503を返すであろう、何かしらコンテナが停止してしまう最小ケースを作ってみます。通常はawait Bun.file('/etc/passwd').text()というコードを実行しても問題なくファイルを読み出すことができます。

% docker run --rm -ti oven/bun:1.0.14-slim bun repl
Welcome to Bun v1.0.14
Type ".help" for more information.
[!] Please note that the REPL implementation is still experimental!
    Don't consider it to be representative of the stability or behavior of Bun overall.
> await Bun.file('/etc/passwd').text()
'root:x:0:0:root:/root:/bin/bash\n' +
...

次に Installation - gVisor よりgVisorをインストールした上で、コンテナランタイムにrunscを指定して動かしてみます。

% docker run --rm -ti --runtime=runsc oven/bun:1.0.14-slim bun repl
Welcome to Bun v1.0.14
Type ".help" for more information.
[!] Please note that the REPL implementation is still experimental!
    Don't consider it to be representative of the stability or behavior of Bun overall.
> await Bun.file('/etc/passwd').text()
(ここでコンテナごと停止)

runscを指定した場合は、実行できずコンテナが停止しました。 Debugging - gVisor を見るとログを吐き出せるようなので、設定すると以下のログでエラーになっていることがわかります。

I1210 12:46:59.766212    2410 strace.go:564] [   9:  33] bun E io_uring_setup(0x100, 0x6c45b65b9d18)
I1210 12:46:59.766224    2410 strace.go:602] [   9:  33] bun X io_uring_setup(0x100, 0x6c45b65b9d18) = 0 (0x0) errno=38 (invalid system call number) (1.125µs)

そう、io_uringです。このケースでは内部でio_uring_setupのシステムコールが呼ばれることから、コンテナが停止していました。

Linux/amd64 - gVisor をみるとio_uring_setupはpartial supportとされていますが、以下のコードを見るとそもそもオプションで明示的に有効にしていないとio_uringは使えないようになっていることから、そもそもCloud Run gen1のgVisor上ではio_uringが使えないということになります。*2

github.com

io_uring

近年のio_uringの扱いとしては、パフォーマンス上ではメリットがあるものの、kernel exploitに繋げられるセキュリティ上の問題がいくつも報告されており、以下のGoogleが実施したkCTFの結果としてはexploitの60%ほどがio_uringに関する脆弱性を利用したものであったことから、io_uring自体を無効にする流れとなってきているようです。

security.googleblog.com

また、次期Dockerのリリースでもデフォルトのseccomp profileでio_uringのステムコールはブロックされるようになっています。

ちなみに2023/12/10時点ではまだリリースされていませんが、 最近になって Rewrite IO for Bun.file() by Jarred-Sumner · Pull Request #7470 · oven-sh/bun · GitHub でio_uringが使われないように書き直されていました。 イメージにoven/bun:canary-slimを指定してrunscで動かすと落ちません。なんとタイミングの悪いこと……

% docker run --rm -ti --runtime=runsc oven/bun:canary-slim bun repl
Welcome to Bun v1.0.16
Type ".help" for more information.
[!] Please note that the REPL implementation is still experimental!
    Don't consider it to be representative of the stability or behavior of Bun overall.
> await Bun.file('/etc/passwd').text()
'root:x:0:0:root:/root:/bin/bash\n' +
...

まとめ

  • Cloud Run上でCTF問題をデプロイする場合は必ずgen2を使う
  • gVisor上ではio_uringは使えない
  • 次期Bunはio_uringを使わなくなる
  • 次期Dockerでもデフォルトのseccomp profileではio_uringが使えなくなる

ちょうど様々が重なった結果引き起こされた悲劇、ということでここに供養します。よい作問ライフを。

(この記事は CTF Advent Calendar 2023 - Adventar の9日目です)

*1:speedrunではあるのでよくあるjs sandbox問でのテクニックで抜け出す、としてもよいかと思いルール自体は雑に作っていました

*2:runtimeArgsに--iouringを指定するとio_uring_setupは通るようになるのですが、その後にまた別の理由で落ちます。深追いはしないでおきます……