目次


FD(File Descriptor)リーク

コンテナといえば”名前空間の隔離”という印象が強く、名前空間が隔離できていればホスト側のリソースには原則到達できない、と思われがちですが、これは間違いです。
その代表例がFDを経由したホストリソースへのアクセス=FDリークです。
FDリークを簡単にまとめると、

という、権限境界を跨ぐ際の処理漏れ により発生するものです。
ホストで開いたリソース/FDというと特別なファイルと思うかもしれませんが、単純なテキストファイルも含みます。

PoC

これは実際の動きを見てみるのが早いです。 流れとしては以下になります。

  1. 親プロセス側で開いたファイルを意図的にクローズせずに
  2. 子プロセスを実行する

これだけです。

まずはテスト用ファイルとして、ホストの/tmpにファイルを作成します。

echo "TOP_SECRET_123" > /tmp/raind-fd-secret
chmod 600 /tmp/raind-fd-secret

次に、親プロセス側(createコマンド)にて、以下のコードを埋め込みます。

func (c *ContainerCreator) Create(opt CreateOption) (err error) {
    // == PoC: FD Leak ==
    fd, _ := unix.Open("/tmp/raind-fd-secret", unix.O_RDONLY, 0) // without O_CLOEXEC
    _ = fd
    // ==================

	var (
		spec  spec.Spec
		event = "create"
		stage string
		pid   int
	)

これで親プロセスで/tmp/raind-fd-secretに対するFDが生成されます。 ポイントは、unix.O_CLOEXECフラグがない点です。このフラグがない場合、明示的にfd.Close()を呼ばない限りFDが開き続けます。
この親プロセスはFDを開いたまま、以下の処理でinitプロセス(=コンテナ)を起動します。 ここは本番コードから変更はありません。

func (c *containerInitExecutor) executeInit(containerId string, spec spec.Spec, fifo string) (int, error) {
        :
    initArgs := append([]string{"init", containerId, fifo}, entrypoint...)
    cmd := c.commandFactory.Command(os.Args[0], initArgs...)
        :
    // execute init subcommand
    if err := cmd.Start(); err != nil {
    	return -1, err
    }
        :

commandFactory.Commandはos/exec.Cmdのラッパーですが、親プロセスで開いているFDは子プロセスに継承するという仕様になっています。
これでコンテナを起動し、/proc/self/fdの中を確認してみます。

raind container run -t ubuntu
root@01kff8gd7ms8:/# ls -l /proc/self/fd
total 0
lrwx------ 1 root root 64 Jan 21 03:11 0 -> /dev/pts/11
lrwx------ 1 root root 64 Jan 21 03:11 1 -> /dev/pts/11
lrwx------ 1 root root 64 Jan 21 03:11 2 -> /dev/pts/11
lr-x------ 1 root root 64 Jan 21 03:11 3 -> /proc/13/fd
lrw------- 1 root root 64 Jan 21 03:11 7 -> /tmp/raind-fd-secret # host resource

root@01kff8gd7ms8:/# cat /proc/self/fd/7
TOP_SECRET_123

fd:7として/tmp/raind-fd-secretが見えており、catで中身も確認できてしまっています。
このコンテナは

も適用しており、明示的にホストのディレクトリをマウントしたわけでもありません。
にもかかわらず、コード内で開いたファイルを閉じ忘れたままコンテナを起動した結果、FDとしてコンテナ内からホストに対する経路ができてしまいます。
コンテナランタイムの実装観点での脆弱性、これがFDリークです。

名前空間とFD

名前空間の隔離等を行ったにもかかわらずホストのリソースが見えている理由を深堀してみます。
初めに結論から記載すると、

これが理由です。

mount名前空間/pivot_rootが何をしているか

ファイル関連というとmount名前空間が真っ先に思いつきますが、mount名前空間がやっていることは

を変えています。重要なのは、FDが指すinodeを再検証しない点です。

また、pibot_rootも同様に、ルートからの名前解決緒起点を変えるだけであり、 すでに開かれているFDに対しては何の影響もないです。

/ <- ホストのrootfs
├── bin/
├── tmp/                       accessable
│    └── raind-fd-secret <──────────────────────┐
└── etc/                                        │
     └── raind/                                 │
           └── rootfs/ <- container's rootfs    │
                     ├── bin/                   │
                     ├── root/                  │
                     ├── home/                  │
                     └── fd/                    │
                          └── 7 inode(/tmp/raind-fd-secret)

inodeとは簡単に言うと”ディスク上での位置情報”を示しているのであって、”パス名による位置情報”ではありません。 わかりやすく例えるとすれば、

という感じです。

mount名前空間を隔離/pivot_rootを行ったとしても、ディスク上の位置(=inode)を知っていれば理論上はホストのリソースへすべてアクセスできてしまうのです。

対策

FDリークに対する対策は2つあります。

1. unix.O_CLOEXECを付ける/os.Openを使う

根本的な対策は、ファイル操作を行うスコープ終了時に確実に閉じることです。
unix.Open/Openatなどの “golang.org/x/sys/unix” に含まれるOpen系は、明示的にクローズしなければPoCと同様にFDが開き続けます。
これらを使う場合は、必ずunix.O_CLOEXECをフラグにセットするようにします。

unix.Open("/tmp/raind-fd-secret", unix.O_RDONLY|unix.O_CLOEXEC, 0)

または、os.Openを使うことでも同様の対策ができます。os.Openには内部でO_CLOEXECがセットされているため、意図せずFDが開き続けることを防ぎます。

os.Open("/tmp/raind-fd-secret")

コンテナランタイムではMount処理やネットワーク、Socketといった単純なファイル以外にもFDを開く処理が多いため、lintなどでunix.O_CLOEXECが付いているか、os.Openを使っているかなど、これらの検査を徹底します。

2. Entrypoint実行前に不要なFDを閉じる

上記はコード実装面での対策ですが、それでもClose漏れが発生したり、ライブラリの動作変更によりFDがクローズできない場合があります。
そこで、Entrypoint実行前に不要なFDは全て閉じる処理を入れます。具体的には、

この3つ以外にFDがあった場合、それは親プロセスから子プロセス(=コンテナ)に対して渡されたFDであり、設計上は子プロセスに渡すFDは存在しません。
そこで、以下のような実装でFD:0/1/2以外を閉じます。

func (c *ContainerInit) closeAllExcept012() {
	ents, err := os.ReadDir("/proc/self/fd")
	if err != nil {
		return
	}
	for _, e := range ents {
		fd, err := strconv.Atoi(e.Name())
		if err != nil || fd < 3 {
			continue
		}
		_ = syscall.Close(fd)
	}
}
    // エントリポイント実行前にClose
	// close all FD except 0,1,2
	c.closeAllExcept012()
	// execve
	err = c.syscallHandler.Exec(arg0, entrypoint, spec.Process.Env)
	if err != nil {
		return err
	}

これで万が一FDの閉じ忘れが発生した場合でも、initプロセス内で明示的にクローズできます。

対策後の動作確認

PoCで準備したファイルと意図的に入れたFDを開き続ける処理はそのまま、closeAllExcept012()を組み込んだ状態でコンテナを起動します。

raind container run -t ubuntu
root@01kff8gd7ms8:/# ls -l /proc/self/fd
total 0
lrwx------ 1 root root 64 Jan 21 03:11 0 -> /dev/pts/11
lrwx------ 1 root root 64 Jan 21 03:11 1 -> /dev/pts/11
lrwx------ 1 root root 64 Jan 21 03:11 2 -> /dev/pts/11
lr-x------ 1 root root 64 Jan 21 03:11 3 -> /proc/13/fd

先ほどと異なり、/tmp/raind-fd-secretへのfdが存在しないことが確認できました。