コンテナはなぜ安全(または安全でない)なのか

CVE-2019-5736を覚えていますか?今年の2月に見つかったrunc(Dockerがデフォルトで利用しているコンテナのランタイム)の脆弱性で、ホストのruncバイナリを好き勝手にコンテナ内部から書き換えることができるというものです。

脆弱性の仕組みに興味があったので調べたところ、コンテナを攻撃する方法というのは他にもいろいろあって、runcは頑張ってそれを塞いでいるようです。これまとめると面白いかも、と思ったので以下のようなおもちゃを作りました。

Drofuneは簡単なコンテナランタイムです。drofune runとかdrofune execなどでコンテナを起動したり、入ったりすることができます、といえば想像がつくでしょうか。

これだけでは何も面白くないので、Drofuneはわざと安全でない実装になっています。なので、今回発見されたCVE-2019-5736を利用した攻撃も成立します。このプロジェクトは攻撃コードがどのように動き、どのように防げるのかを試すことが目的です。

Linuxコンテナの仕組み

調べるといろいろ出てくるので、ここでは詳しく説明しませんが、大雑把に言えばLinuxカーネルのnamespaces(7)を使って分離された環境を実現しています。名前空間はプロセスIDやマウントポイントごとに存在し、clone(2)などでフラグを渡すと、複製されたプロセスをそれぞれ新しい名前空間で実行することができます。

Linuxカーネルが環境を分離してくれるので、ファイルを消しても影響はないし、プロセスが見えないので、環境変数を盗み出したり、制御を奪ったりすることができません。これによってコンテナは分離された安全な環境を実現しています。

コンテナ作りの大まかな流れは

  1. コンテナで実行するコマンドをCLIで受け取る(/bin/bashなど)
  2. clone(2)でプロセスを複製し、新しい名前空間上で実行する
  3. 新しい名前空間で走っているプロセスでexecve(2)などしてコマンドを実行

という感じです。実際にはマウントポイントを分離しても、ホストと同じファイルシステムを向いたままだと意味が無いので、別のファイルシステムを用意して、chroot(2)などしてルートを変える必要があります。

runcは大体ここで似たようなことをしています。いろいろなプラットフォームで動かすことを想定しているので、unshare(2)したり、孫プロセスまで生成したり、いろいろやっているのですが、あんまりよくわかっていません。

面白いのは、runcはGoで書かれているので、forkとかcloneができず、代わりに別のコマンドを再実行することで、新しいプロセスを名前空間に紐づけているところです。/proc/self/exe initを実行し、initのときだけこのnsexec.cが発火するようにしています。(/proc/self/exeは自身を指す特別なファイルで、runcを実行中ならば、runcのバイナリ自体を指します)

l := &LinuxFactory{
    Root:      root,
    InitPath:  "/proc/self/exe",
    InitArgs:  []string{os.Args[0], "init"},
    Validator: validate.New(),
    CriuPath:  "criu",
}

https://github.com/opencontainers/runc/blob/v1.0.0-rc8/libcontainer/factory_linux.go#L138-L144

import (
    "os"
    "runtime"

    "github.com/opencontainers/runc/libcontainer"
    _ "github.com/opencontainers/runc/libcontainer/nsenter"
    "github.com/urfave/cli"
)

https://github.com/opencontainers/runc/blob/v1.0.0-rc8/init.go

libcontainer/nsenterのREADMEにも説明がありますが、importすることによって処理が挟み込まれます。initを実行するときだけimportしているので、このときだけ発火するわけです。まったく直感的でないので読むのが辛いですね。いや、文句を言っても仕方ないんですが...

起動したコンテナに入る(docker exec, runc exec)のは、起動したプロセスを別プロセス(コンテナ)の名前空間に関連付けることによって実現できます、これにはsetns(2)を使います。runcはコンテナの起動時と似たような仕組みでこれをやっており、細かな制御はファイルディスクリプタを経由してデータを書き込むことでやっているようです。

Drofuneも似たようなことをやっていて、コンテナの起動はcloneして子プロセス側でホストと同じファイルシステムを用意してchrootするという感じです。

// Create a container init process.
// Note that the init process starts on the host.
pid_t pid = syscall_clone();
if (pid < 0)
  return 1;

// syscall_clone() behaves like fork(2).
// If the pid is zero, the process has run in new namespaces.
if (pid == 0)
  return init_process(dir, commands, ctx);

https://github.com/wata727/drofune/blob/7d67f135e8c36279a6b1b61138e2444dc71775b5/run.c#L34-L43

コンテナに入るのは、コンテナプロセスの名前空間にsetnsしてforkするだけ。PID名前空間に参加するためにfork(2)が必要なことに注意。

// Join container's namespaces.
pid_t pid;
if (ctx.secure_join) {
  pid = secure_enter_namespaces(target_pid);
} else {
  pid = enter_namespaces(target_pid);
}
if (pid < 0) return 1;

// Exec commands in the container.
if (pid == 0)
  return exec_process(commands, ctx);

https://github.com/wata727/drofune/blob/7d67f135e8c36279a6b1b61138e2444dc71775b5/exec.c#L56-L67

なお、Drofuneの実装のベースはMINCSというシェルスクリプトベースのコンテナランタイム実装を非常に参考にしています。作者さんによる日本語の解説記事もあるので、詳しくコンテナの仕組みを知りたい人はぜひ読むといいでしょう。

攻撃例

さて、コンテナの仕組みが大まかにわかったところで、今度はどのようにしたらコンテナが安全でなくなるのか見ていきましょう。いくつかの攻撃コードの例がexploitsの中にあります。

chroot監獄からの脱獄

前述した通り、chrootを利用して、コンテナからホストのファイルシステムを見えないようにしているのですが、実は脱出できます。ルートを変更されているんだから、普通に考えれば無理そうな話ですが、chrootは現在のディレクトリを変更しないというのを利用して脱出することができます。

右がコンテナ内部で悪意あるコードを実行した例、左がホスト環境です。存在しなかったファイルがホストに作成されてしまったことがわかると思います。これはコンテナ内部からtouch hackedがホスト上で実行されたことを意味しています。

作業ディレクトリの下に一時的なディレクトリを作って、そこへchrootします。chrootは現在のディレクトリを変更しないので、作業ディレクトリがルートディレクトリの上にあるという奇妙な状態になります。後はcd ..で上に登っていくと、いつか本来のルートにたどり着くので、そこで任意のコマンドを実行するだけです。

この攻撃をどうやって防ぐのかというと、pivot_root(2)を使うという方法があります。pivot_rootでは元のルートディレクトリを別の場所に逃がすことができるので、この方法ではホストのルートディレクトリにたどり着くことができません。Drofuneでは--pivot-rootオプションで試すことができます。

runcもpivot_rootを使っているようです。MINCSはpivot_rootへのパスを用意していますが、chrootでも動くように、CAP_SYS_CHROOTを落としているようですね。これによって、コンテナの中でchrootができなくなるので、攻撃が成立しません。Drofuneでは--drop-capsオプションで同様の状態を再現できます。

攻撃コードの例は以下の記事を参考にしました。

安全でない名前空間への参加

この攻撃例はCVE-2019-5736の詳細レポートにある、Failed approachesを参考にしています。起動しているコンテナに入る、というのはプロセスをその名前空間に関連付けることである、というのは前述した通りです。ちょっと必要なことを整理してみましょう。

  • 名前空間(PIDやマウント、ネットワークなど)ごとにそれぞれsetnsする必要がある
  • まとめてsetnsする方法は無いため、順々にsetnsする必要がある
  • PID名前空間に入るためにはfork(2)が必要

ポイントは、forkした時点でそのプロセスはコンテナ内部から乗っ取ることが可能であるということです。ptrace(2)という魔法のようなシステムコールがありますからね。つまり、他の名前空間に入る前に、PID名前空間に入ってforkすると、ホストのファイルシステムなどが見える状態で乗っ取られる可能性があります。

右がコンテナ、左がホスト環境です。右下のコンソールからコンテナに入ろうとした瞬間に、プロセスを乗っ取られて、任意コードを実行され、ホストにファイルが作成されています。

ptraceでプロセスを乗っ取ることができれば、任意のコードを実行させることができます。マウント名前空間に入る前であれば、ホストに対して実行させることができます。

問題のコードはこの辺です。Drofuneではちょっとズルをしていて、ptraceで確実にプロセスを捕まえるためにsleepを挟んでいます。なので、現実問題としては他の名前空間に入るまでの瞬間が短すぎて、ほとんど問題にはならない気がします。

for (i = 0; i < namespaces_count; i++) {
  struct namespace ns = namespaces[i];
  if (enter_namespace(target_pid, ns) < 0) {
    return -1;
  }

  // Certainly, you need to fork to enter the PID namespace, but you MUST NOT fork before entering all namespaces.
  // For sleep(3), assume another long process. This is necessary for an attack vector.
  // See exploits/insecure_join
  if (ns.value == CLONE_NEWPID) {
    pid = fork();
    sleep(1);
    if (pid > 0) {
      return pid;
    }
  }
}

https://github.com/wata727/drofune/blob/7d67f135e8c36279a6b1b61138e2444dc71775b5/exec.c#L104-L120

ちょっとわざとらしいコードに見えるかもしれません。が、関連付ける名前空間がユーザーによって変更可能だったり、CLONE_NEWPIDが含まれていないならばforkしないみたいな仕様だったら、こういうコードも書いてしまいそうです(よね?)。

これを防ぐ方法は単純で、すべての名前空間に入った後にforkすればいいだけです。--secure-joinオプションをつけて実行すると、コンテナ内部にファイルが作成されます。

runcはすべての名前空間に入った後にforkしているためこの攻撃は成立しません。加えて、デフォルトではptraceをコンテナから実行できないようになっています。

なお、ptraceによるコードインジェクションのテクニックは「コンピューターハイジャッキング」という本を参考にしました。

コンピュータハイジャッキング

コンピュータハイジャッキング

CVE-2019-5736

今回のきっかけになった脆弱性です。「安全でない名前空間への参加」はコンテナに新しいプロセスが入ってくるタイミングを狙った攻撃ですが、それに似ています。詳細は報告者のレポートを読んでください。

簡単に説明すると、ファイルシステムは通常分離されていますが、/proc/pid/exeはホストのバイナリを指すシンボリックリンクになっているため、これを経由してコンテナ内部からホストのバイナリを書き換えられるというものです。

右がコンテナ、左がホスト環境です。右下のコンソールからコンテナに入った後、シェルを終了した瞬間にバイナリが書き換えられていることがわかると思います。

これの回避策はかなり面白くて、ホストからバイナリを実行するときに、直接実行するのではなくて、一度メモリにバイナリ自体をロードしてから、それを実行するようにしています。(cloned_binary.cはruncで採用されたパッチそのものです)

// At first glance, you may not understand why it needs this implementation.
// However, it allows access to the host binary from within the container through /proc/pid/exe if do not clone the binary.
// See exploits/CVE-2019-5736
if (ctx.clone_binary) {
  if (ensure_cloned_binary() < 0) {
    perror("ensure_clone_binary");
    return 1;
  }
}

https://github.com/wata727/drofune/blob/7d67f135e8c36279a6b1b61138e2444dc71775b5/exec.c#L24-L32 https://github.com/wata727/drofune/blob/7d67f135e8c36279a6b1b61138e2444dc71775b5/cloned_binary.c

これによって、/proc/pid/exeがホストを指さなくなるので、コンテナからホストを攻撃する手段がなくなります。Drofuneでは--clone-binaryフラグで試すことができます。

いやー、この脆弱性を知っていないと絶対書かないコードでしょうねぇ。

試したけどダメだったもの

他にもうまく再現できなかった攻撃手法があるので紹介します。

open_by_handle_at(2)を使ったホストのファイル読み出し

ファイル読み出しの権限チェックをバイパスできるケイパビリティをコンテナが持っていれば、このシステムコールを使ってinode番号を指定することでホストのファイルを読み出すことができる... はずだったのですが、DrofuneではStale file handleエラーになってしまい、再現できませんでした。OverlayFSだとダメなのかもしれませんが、詳しく調査できていません。

CVE-2016-9962

以前報告されたruncの脆弱性です。ファイルディスクリプタを閉じ忘れたままコンテナに入ることで、それ経由でディレクトリトラバーサルできるという話だと思うのですが、ちょっとうまく再現できませんでした。

備考

open_by_handle_at(2)の記事を書かれていた方のコンテナセキュリティについて説明していたスライドが参考になりました。最初にこれを見つけていれば結構近道できたかもですね...

Linuxあんまり詳しくないので、間違ってたらTwitterかなんかでこっそり教えてください。