pwnable challengeをホストする際にしていること

CTF Advent Calendar 202020日目の記事です。一つ前の記事はptr-yudaiさんのオレオレFuzzerもどきを利用してCTFのpwnableを解こう - CTFするぞでした。

この記事では、CTFのorganizeをするときに、私がpwnableの問題をホストする際どのような形で問題環境を作っているか(問題のパッケージやインフラの構築など)について簡単にまとめます。理由としては、

  • こういう記事があまり見当たらない(のでこれからpwnable challengeをホストする人の多少の参考になれば)
  • 私自身セキュリティの専門家ではないので、自分のインフラがどのような危険を内包しているかどうかがあまり分からないので、場合によってはツッコミを頂きたい

という2点です。特に後者の点が大きくて、pwn challengeを通してサーバーを乗っ取られた上で他のフラグなどがリークしてしまった場合、被害が甚大です。なので今後のCTF大会の健全な開催のためにも、もし何か気づいたらお知らせください。

一応私のCTFのorganize経験としては

  • TSGCTF 2回
  • TSG Live CTF 2回
  • SECCON CTF 2回 (これはorganizeと言ってしまうとよくないかもしれません、自分のpwnableのインフラを管理していただけなので)

で、ここまで環境設定ミスで問題が破壊されたり、変にフラグリークしたという経験はないです(気づいてないだけかもしれませんが)。

なお、基本的にただdockerで起動したりtcpdumpで監視したりしているだけなので、そういうところに面白さを求めている方がいらしたら、ごめんなさい。習ったわけではないですが、多分普通です。

前提

pwnableの問題をホストするということは、そのバイナリと同程度の権限で、攻撃者がサーバーのシステムにアクセスできるということを意味しています。なので、当然ですが、「適切な環境の分離」というものが必須になります。このとき分離に対して評価軸として次のようなものを頭に入れています

  • attack surfaceの大きさ
  • シェルを取られた際に起こりうる危険の大きさ
  • かかるお金
  • 手間、面倒くささ

おそらく現実のシステムを考える上でもこういう考えがもっと一般化されて、形式的に議論されているのだと思いますが、私はセキュリティの専門家ではないのでそういう話は知りません。以下ではVMの分離の方がコンテナの分離より、attack surfaceが小さいので安全だが、VMの分離は値段的に高い、として話を進めています。

また最後に、手間、面倒くささと書きましたが、これもこの記事で常に現われる評価軸です。私はpwnの問題を出したいだけであって、かっこいいインフラにすることにそれほど興味が無いので(もちろんかっこいいほうが良いですけど)、出来る限りインフラを設定するのにかかるコストは小さい方が良い、ということを意味します。工数が半日以上かかりそうなインフラの設定はしたくありません。

問題のセッティング

問題のパッケージ

基本的に問題環境のパッケージはdockerを用いて行っています。理由は導入が楽だからです。例として、TSGCTF 2のBeginner's PwnのDockerfileを見てみます

FROM ubuntu:20.04


RUN apt-get update && \
        apt-get -y upgrade && \
        apt-get install -y \
            xinetd \
            iproute2

RUN groupadd -r user && useradd -r -g user user

COPY --chown=root:user ./build/start.sh /home/user/start.sh
COPY --chown=root:root ./build/ctf.conf /etc/xinetd.d/ctf
COPY --chown=root:user ./build/flag /home/user/flag
COPY --chown=root:user ./dist/beginners_pwn /home/user/beginners_pwn

WORKDIR /home/user

RUN chmod 444 ./flag && \
    chmod 555 ./beginners_pwn && \
    chmod 555 ./start.sh && \
    chmod 444 /etc/xinetd.d/ctf
USER user
EXPOSE 30002

CMD ["xinetd","-dontfork","-f","/etc/xinetd.d/ctf"]

基本的にやっていることは

  • 必要パッケージのインストール
  • 必要ファイルのコピー
  • 権限の設定
  • 非rootユーザーの追加と設定
  • 起動コマンドの設定

です。これは趣味ではあるのですが、「シェルをとってほしい」という気持ちをより強くしたい、つまりflag名guessはされたくないと思う場合は、Dockerfileの中でさらにflag名にmd5sumをつけるなどをしても良いと思います(ptr-yudaiさんがこのようにしています

これをビルドして、イメージとして保存すれば、ほぼ完全に環境を固定化できますし、そうでなくてもdocker buildで基本的に必要なセットアップができるので便利です。 ただしDocker imageを固定してもなお、しばしば背後のカーネル依存の挙動が重要になったりするケースがあり注意が必要です。今まで遭遇したことがあるのは、

などがあります。とはいえ、あなたのソルバが本番環境に特に問題なく(個別のデバッグなく)刺さるなら、基本大丈夫じゃないですかね。 まあ、pwnのインフラを整備するときには、変なguessingが入らないことをいずれにせよ注意する必要がありますし、とはいえ吸収しきれない場合が多いのでclarで対処をするしかないです。

サーバー化

私はxinetdを使っています。いつも使っているconfigは次のようになっています

service ctf_beginners_pwn
{
    type = UNLISTED
    protocol = tcp
    socket_type = stream
    port = 30002
    wait = no
    disable = no
    user = user
    server = /bin/sh
    server_args = /home/user/start.sh
}

他の候補としては、socat/ynetdがあると思いますが、この辺の違いはあまりわかりません。勝手にsocatよりxinetdの方が信頼できると思っています。

タイムアウト

xinetdにそういう機能が無いと思うので、timeoutは起動コマンドに外部からかけています https://github.com/tsg-ut/tsgctf2/blob/master/pwn/beginners_pwn/build/start.sh

timeout -s 9 60s ./vuln

バッファリング

問題によっては、外部からIOのバッファリングを切る必要があります。これにはstdbufというものを使っています。

stdbuf -i0 -o0 -e0 ./vuln

インフラの設定

ここが今回の一番の問題です。

まず話の流れとして、問題のデプロイについて話すと、基本的には上でパッケージしたdocker imageをそのままdocker runで実行しています。正確にはdocker-composeを用いて

version: '3'

services:
    ctf:
        restart: always
        build: ./
        read_only: true
        ports:
            - '30005:30005'

のように、必要な設定を追加しています。

次に具体的にどういうマシンを使っているかについて話すと、TSG CTFの場合はGCP、SECCONの場合はSakuraからVMを借りてその上に環境を作っています。特筆するとすれば、TSG CTFでは、Container Optimized OSというOSを利用しています。基本コンテナがその上で動作するという仮定を置いた上でのセキュリティの設定などがデフォルトでパッケージされているらしいです。詳しくは知りませんが、ボタンを押すだけなので簡単です。 なお、このOSではパッケージを入れたりするのがかなり難しく、docker-composeを入れるのが自明ではありません。そのための簡単なwrapperをこおしいずさんが作ってくれたので、ありがたく活用しています。 https://github.com/tsg-ut/tsgctf2020/blob/master/tools/easy-docker-compose.sh

そして、このマシンの上で複数のpwnの問題をコンテナにより分離を信じて、配置しています。このようにすることの利点は、VMが一つで済むので価格が安いです。一方で、コンテナの分離は、VMの分離より(一般的に)弱いので危険と言えば危険です。どっちを取るべきかはコンテストの重みによっても変わると思いますが、基本的には信じて問題ないと思って、4回ほどコンテストを開催しました。多分何も起こってません。Docker Escape見つけたらTSG CTFなんかで使わないでください。逆に言えばDocker Escapeが見つかったりしない限りは安全だと思っています。

事例集

「典型的なpwnの問題の例」 TSGCTF 2: Beginner's Pwn

一番典型的なPwnの問題です。こういった問題は、「一般的に普通のコンテナの使い方」で実行できるので、「かなり安全な部類」だと思っています。なので、この部類の問題サーバーは同じVMに同居させています。 TSGCTFではお金をケチって、区別していませんでしたが、リスクとしてはpwnとcryptoの問題では明らかにpwnの問題の方が(シェルをとらせるので)危ないことが多いので、こういったリスクでVMをわけるのも良いと思います。

「内部でコンテナ化をしている問題の例」 TSGCTF 2: std::vector

問題の内部での環境を分離するためにコンテナを使っているタイプの問題です。 この問題は内部でコンテナによる分離を必要としています。つまり、問題サーバーもDockerでデプロイするとなると、Docker in Dockerが必要ということです。

Docker in Dockerの嫌なところはprivilegedをつけて実行する必要がある点です。これにより、通常のコンテナよりもアクセスできるデバイスへの自由度が増えるので、シェルを取られた時により危険な状態であると言えます(ちなみに、問題の設定として、privilegedなコンテナでのシェルは、pythonやhashcashにバグが無ければ、取れないはずということになっています。つまりシェルが取れるのは入れ子になったコンテナの中だけです)。

このリスク評価が正しいかはわからないですが、(主に無知なので怖いから)TSGCTF 2ではprivilegedコンテナを使う問題だけは、VMを別にして安全を優先しました。この付近に関しては私は知識不足なので、似たような設定で問題を作っている方がいらしたら、どういうふうに設定しているかを教えて頂きたいものです。

ちなみにDocker in Dockerテクですが、DinDのImageはBASEがalpineになっていてあまり使い勝手がよろしくないが、自分でDinDするための設定をUbuntuのbaseイメージに対してしていくのは、面倒だったので、中のコンテナでは実はDockerではなくnsjail を使っています。 このようにすると、Dockerfileの外側の設定の見た目はほとんど変わりません。

https://github.com/tsg-ut/tsgctf2020/blob/master/pwn/stdvec/Dockerfile

FROM moratorium08/nsjail

RUN apt update && apt upgrade -y && apt install -y python3.7 nodejs npm xinetd iproute2
...

違うのはnsjailのイメージを使っている点とdocker-compose.ymlでprivilegeを指定している部分だけです。

「お金がいっぱいある例」 SECCON

SECCONのようにお金がある場合は全部の問題ごとにVMで分離しています。これが手間もかからず、安全性も高いので、お金が気にならないならこれが良いと思います(し、SECCONの場合ミスってコンテストが崩壊した場合にはかなり困ったことになるので、そうするべきなんだと思います)。

監視

CTFの開催というのは基本的には慈善事業なのであまり悪意のあることをされると(異常な量のペイロードを投げるなど)結構困ります。もちろん外側からの監視で通常あるべきでない量のペイロードを投げる人を見つけることはできますが、「悪意」がありそうかどうかの判定は案外難しいです。せっかく開催したCTFに参加してくれているので、できればbanはしたくありません。 また、想定外の行動(明らかにアクセスできるべきでないファイルにアクセスしているなど)をされたり、これはあまり良くない話ですが、想定外の解法で解かれていることもあります。もちろん対策を取れるかどうかは別問題ですが、開催中にこういったことに気づいておきたいところではあります。 つまり、適切な監視インフラを構築したいですが、これもまた「面倒なことはあまりしたくない」という別のモチベーションがあります。

これをするには私は、VMに直接tcpdumpをインストールした上でこのスクリプトを、問題ごとにsupervisordに登録して、dumpを吐き出させ、それをflowerというツールで監視しています。

この辺はあまり満足度が高くなくて、手元でパケットを解析するために定期的にパケットのファイルを手元にダウンロードしたり、それをflowerに流したりする部分があまりきれいではありません。上手な方法を御存知の方がいたら教えて下さい。

ちなみに、flowerの流し込み部分ですが、私の手元のメモによれば次のようにやるらしいです(汚いね)

なんか確かimport.pyで使われているライブラリのインストールがかなり難しいはずで、しかしこのdocker imageの中では動くので、このような汚いことをしているはず、全ては怠惰のため。

あと留意点として、(これはあるあるだと思いますが)tcpdumpで分割された最新のファイルをダウンロードしてimportするとそのファイルは不完全なので壊れがちです。ダウンロードするときは最新から一個手前までのもののみにすると良いです。

その他

Proof of Work

Proof of Workを問題によっては入れる場合があります。もちろん入れないほうがストレスも無いし良いんですが、一回の接続で問題サーバー側のリソースをそれなりに使わないといけない状況の場合には、基本的には「一人一票の原則」を適用させたい場合があります。とはいえ、PoWがあるのは当然ですが問題を解く側の経験としてプラスになることはないので、出来る限り回答者の気持ちに寄り添う必要があると思います。

これは単なる私の持論ですが、PoWを入れる場合(入れなければいけないような問題の場合)は次のような点に注意しています

  • サーバーを動作をローカルで再現できて(ソースコード、バイナリなどが配られていて)、回答者がサーバーに接続する必要があるのは最後だけ
  • PoWをするためにコードを書かせない(PoWソルバを書くのも手ですがこの場合ここに最適化の余地を生んでしまうとお互い困るので、既存ツールでインストールの手間がかからないものを使うのが良いというのが私の見解です。私はhashcashを使っています)

readn

さらに細かいですが、たまに見るのでコメントだけ残しておくと、少し大きめのバッファが必須(例ROPの長めのペイロードなど)のときに、直接read(0, buf, N)のようなことをするプログラムがある場合がありますが、これはネットワーク越しだと不安定になるので良くないです(文字数制限をつけたいなら明示的にすべきで)。

なので、よくあるシステムズプログラミングの課題のように、Nバイト受け取らなければいけない場合は、必要なバイトを受け取るまでループを回すコードを書きましょう。

最後に

何か気づくことがあればコメント頂けると幸いです。

明日はakiko_pusuさんの「1週間経って少しはすすんだでしょうか?書いてみます!」です。楽しみですね。