LXCで学ぶコンテナ入門 -軽量仮想化環境を実現する技術

第47回非特権コンテナの可能性を広げるseccomp notify機能

前回の連載が掲載されたあと、久々にコンテナの勉強会をオンラインで開催しました。2回に渡って、cgroupをテーマにカーネルの実装に踏み込んだ内容のお話が聞けました。私もcgroup v1の内部構造についてお話しました。動画は公開されていますのでぜひご覧ください。

さて、今年も気がつけばもう12月で、Advent Calendarの季節になりました。今年はいろいろなことがありましたが、今振り返るとあっという間だった気がします。今年もこの連載で毎年参加しているLinux Advent Calendarに参加します。この記事はLinux Advent Calendar 2020の15日目の記事となります。

この連載は、名前に「LXCで学ぶ」と付いているわりには、最近まったくLXCが出てきませんでしたが(^_^;)、今回は久々にLXCコンテナを使って機能の説明をしたいと思います。とは言ってもLXCを直接使うわけではなく、LXDを通して操作してみたいと思います。LXDに関してはUbuntu Weekly Recipeで結構取り上げられていますので、LXDについて詳しく知りたい場合はぜひご覧ください。

非特権コンテナが行う操作

この連載の第16回で紹介したように、Linuxカーネルにはユーザ名前空間(User Namespace)という機能があります。

この機能を使って、コンテナ内ではroot権限を持っているにも関わらずホスト上では一般ユーザ権限しか持たない非特権コンテナが実行でき、セキュアなコンテナ実行環境が実現できるようになっています。

このユーザ名前空間内のrootユーザは、コンテナ内では必要な特権(ケーパビリティ)を持っているように見えるにも関わらず、実際に特権が必要な操作を行うとエラーになることがあります。

これはもちろん、ユーザ名前空間内で特権を保持しているとはいえ、ホスト上のrootと同じ権限を持つと、ホストや他のコンテナを危険にさらす操作ができる可能性があるため、カーネル内部で危険な可能性がある操作に関してはチェックが行われているためです。これはセキュリティを考慮すると必要なチェックです。

このように非特権コンテナ(ユーザ名前空間)内では行えない操作の例として、/dev以下のデバイスファイルを作成したり、ファイルシステムをマウントする操作が挙げられます。このような操作はカーネルの機能を使うため、システムコールを使って行います。

デバイスファイルには、非特権コンテナ内で作成すると危険なデバイスファイルがある一方で、非特権コンテナ内で作成しても安全なデバイスファイルも存在します。例えば/dev/null/dev/zeroなどはコンテナ内で作成しても安全でしょう。

デバイスファイルに関しては、コンテナを起動するために、これまでもコンテナマネージャやランタイムがバインドマウントを用いて必要なデバイスファイルをコンテナ内に出現させていますので、問題になることは少なかったかもしれません。

それでも、非特権コンテナ内のプログラムがデバイスファイルを作成する処理を行うような場合は、事前にコンテナに対して定義を行い、必要なデバイスファイルをバインドマウントで出現させるという方法では対処できません。

ファイルシステムのマウントに関しては、これまでは非特権コンテナ内からは一部の疑似ファイルシステムなどをのぞいてはファイルシステムをマウントできませんでした。このため、事前にホスト上でマウントしてコンテナで利用できるようにするなど、別の方法を採る必要がありました。

事前に必要なマウントがわかっている場合は、このようにコンテナマネージャ上で定義することで解決できます。しかし非特権コンテナ内のプロセスがマウントを行うような場合、コンテナマネージャはプロセスがいつマウント操作を行うのかを知ることはできませんので、事前にコンテナマネージャで必要な操作を行うことはできませんでした。このような非特権コンテナ内からファイルシステムをマウントすることに関しては、これまで長い時間をかけて議論されてきたテーマでした。

しかし、ホストの管理者自身がホスト上で実行するコンテナを管理する場合であったり、ホストの管理者がコンテナの管理者を信頼できる場合は、カーネルで禁止されている操作であっても許可できる場合はあるでしょう。また、同じ操作であっても、コンテナによって許可したり許可しなかったりしたい場合もあるかもしれません。そのような操作をコンテナマネージャが制御できると便利です。カーネルで制御しようとすると、ある操作は一律禁止したり、許可したりという決まった動作でしか制御できません。

現在のようにコンテナ上でアプリケーションを実行することが一般的になってきている状況では、どのような操作が危険で、どのような操作が危険ではないか?というのはコンテナマネージャやコンテナマネージャを使う管理者の方がよく知っているでしょう。コンテナマネージャが許可できる操作のうち、管理者が安全と判断した操作はコンテナマネージャに許可する設定ができるほうが、セキュアで柔軟なアプリケーション実行環境ができるでしょう。

つまり

  • コンテナ内のタスクは非特権コンテナから実行できない操作を行う、つまりシステムコールを発行する可能性があります

このような場合、

  • コンテナマネージャはそのシステムコールを非特権コンテナ内で実行しても安全であることを知っている可能性があります

しかし、

  • コンテナマネージャはいつそのシステムコールが発行されるのか、発行されたのかを知ることができません
  • もし、いつ発行されるのか、発行されたのかを知っていたとしても、必要な検査を行う方法がありません

というような問題がありました。

システムコール

ここまでなんの前提もなく「システムコール」という言葉を使ってきました。今回紹介する機能を説明する前に、簡単にシステムコールについて説明しておきましょう。

通常、ユーザー空間のプログラムは直接OSのリソースを利用するような操作、つまりカーネル空間の操作はできません。カーネルに必要な操作を行う依頼を行うインターフェースとしてシステムコールが準備されています。

これによりカーネルに対する操作の共通的なインターフェースが提供でき、安全にOSリソースを利用できます。もちろんシステムコールを実行する際には、呼び出し側が実行に必要な権限を持っているかどうかのチェックが行われます。

先に書いたデバイスファイルの作成やマウントなども、もちろんシステムコールを呼びます。このようにユーザー空間のプログラムがOSに関わる操作を行う場合、図1のようにシステムコールを使い、結果を受け取ります[1]⁠。

図1 システムコールの呼び出しと実行
図1 システムコールの実行

seccomp

さて、⁠システムコールが発行されたのか知ることができません⁠⁠、⁠必要な検査を行う方法がありません」と書きましたが、実はこのようなシステムコールの発行を検知したり、検査を行う機能はすでにLinuxカーネルには実装されています。

これはseccompと呼ばれ、タスクが発行するシステムコールをフィルタリングする機能です。

seccompは2005年、2.6.12カーネルではじめて導入された機能です。この時点でのseccompは、決められたごく一部のシステムコールのみの実行を許可するだけの機能でした。

その後、3.5カーネルで柔軟な設定ができるseccomp mode 2が導入され、システムコールごとに制限ができるようになりました。また、呼ばれたシステムコールの種類をチェックするだけでなく、システムコールに与えられた引数も検査した上でシステムコールを実行するかどうか決定できます。

フィルタリングの指定は、ホワイトリスト的な指定、ブラックリスト的な指定のどちらでも指定できます。つまり、すべてのシステムコールの実行を制限した上で許可したいシステムコールを指定すできますし、逆にすべてのシステムコールの実行を許可した上で制限したいシステムコールを指定することもできます。

図2 seccompによるシステムコールのフィルタリング
図2 seccomp

図2のように、プロセスを実行する際に、あらかじめシステムコールの実行に関するポリシーを定義しておきます。そして、実際にシステムコールが発行されると、ポリシーに従ってシステムコールを実行するかどうかが判断されます。指定したポリシーは子プロセスにも引き継がれます。

実行が許可されていないシステムコールが実行された場合の動作として、すぐにプロセスを終了させる、設定したエラー(番号)を返すなど、いくつか選択できます。システムコールの実行が失敗しても、システムコールを呼び出したプロセスの実行を中断せずに処理を続けることはできますが、システムコールは実行されません。

seccomp notify

このように、seccompは特定のシステムコール呼び出しを検出し、インターセプトできますので、先に示した問題を解決するのに一番近いところにいる機能であることは間違いありません。

しかし、コンテナ内のプロセス内でシステムコールが呼ばれたことを別のプロセスであるコンテナマネージャが知ることはできません。

また、seccompを使ってシステムコールがあたかも成功したように見せかけることはできます。しかし、いずれにせよ実際にはシステムコールは実行されません。つまりシステムコールの実行を検出し、その実行を許可するか拒否するか以外には選択肢がありませんでした。

そこで、ここまで説明した問題を解決できるようにseccompの機能を拡張したのが、今回紹介する"Seccomp notify"機能です[2]⁠。この機能は5.0カーネルで導入されました。

この機能を使うには、プロセスに設定するseccompフィルタにSECCOMP_RET_USER_NOTIFというフラグを設定します図3の①⁠⁠。するとフィルタがロードされたあと、図3の②のようにカーネルが呼び出し元のタスクにファイルディスクリプタ(fd)を返します。

呼び出し元のタスク自体は、この返されたfdを使って何かをするわけではありません。このfdをコンテナマネージャなど、他のタスクに渡します図3の③⁠⁠。

図3 seccomp notify fdを受け渡し
図3 notify fdの受け渡し

ファイルディスクリプタを渡されたコンテナマネージャは、このファイルディスクリプタに対してioctlを呼び出し、必要なデータが格納されるのを待ちます。

そして、フィルタに設定したシステムコールが実行されると、カーネルは図4の②のようにこのファイルディスクリプタへ通知を送ります。そしてシステムコールの実行はブロックされます。

コンテナマネージャは②で送られた通知を読み取ります。この通知からは、呼び出されたシステムコール、システムコールを呼び出したプロセスのPID、システムコールが実行されたアーキテクチャ、システムコールの引数などがわかります。

図4 seccomp notify fd経由のシステムコールの実行
図4 notify fd経由のシステムコール実行

ここでコンテナマネージャ内で必要な検査を行い、同じファイルディスクリプタへ検査結果を返します図4の③⁠⁠。

もしコンテナマネージャが呼び出されたシステムコールによる操作が許可できる操作であると判断した場合は、カーネルはそのままシステムコールを実行し、結果をシステムコールを呼び出したプログラムへ返します図4の④)※3⁠。

もしコンテナマネージャが呼び出されたシステムコールによる操作を許可しない場合は、エラーを返すと通常のseccompで行うフィルタリングのようにシステムコールを実行せずに呼び出したプログラムへエラーを返します図4の④'⁠⁠。

これがseccomp notify機能の概要です。

LXDでのseccomp notify機能の実装

それではseccomp notify機能の動きを実際に見ていきましょう。先に書いた通り、今回はLXDを使ってコンテナを作成し、seccomp notify機能を設定して機能を試していきます。LXDでは、特に指定しなければ一般ユーザー権限でコンテナが起動します。

LXCプロジェクトの開発者はカーネルにも積極的にコンテナ関連機能の実装を進めており、seccomp notify機能の実装も、LXCプロジェクトの開発者を中心に開発されています。

このため、カーネル側での実装とともにLXDでもseccomp notify機能の実装が進めることができ、カーネルで実装されてからかなり短い期間でLXDにもこの機能が実装されています。

デバイスファイルの作成

最初にseccomp notify機能がサポートされたのは、LXDでは5.0カーネルリリースから2ヶ月後のLXD 3.13です。LXD 3.13では、まずはmknodmknodatシステムコールが使えるようになり、デバイスファイルが作成できるようになりました。

LXDが依存しているLXCでは3.2.1から、libseccompは2.5.0からseccomp notify機能が使えます。LXDはLXC、libseccompを必要としていますので、これらの新しいライブラリが必要です。

LXDでmknodmknodatが使えるようになったと言っても任意のデバイスファイルが作成できるわけではありません。許可されているデバイスは先に紹介した/dev/null/dev/zeroなどの一部のデバイスだけです。詳細は公式ドキュメント日本語訳に記載があります。

それでは、デバイスファイルを作成してseccomp notify機能を試してみましょう。今回の実行例はUbuntu 20.04.1にsnapでインストールしたLXD 4.8という環境で実行しています。snapであれば前述のようなライブラリの依存関係を考えることなく、seccomp notify機能が使える形で作成されていますので安心です。なお、LXDに関してはstableリリースの4.0シリーズでもseccomp notify機能が使えます。

$ snap list lxd 
Name  Version  Rev    Tracking       Publisher   Notes
lxd   4.8      18520  latest/stable  canonical✓  -
$ lxc version
Client version: 4.8
Server version: 4.8

まずはコンテナを作成します。そしてこのコンテナが一般ユーザで起動していることを確認します。

$ lxc launch ubuntu:20.04 c1 (Ubuntu 20.04コンテナの作成・起動)
Creating c1
Starting c1
$ lxc info c1 | grep Pid (コンテナのPIDを確認)
Pid: 41463
$ ps aux | grep 41463
1000000    41463  0.0  0.4 104104  8032 ?        Ss   15:05   0:00 /sbin/init
(UID:1000000の一般ユーザで起動している)

それではコンテナ内に入ってデバイスファイルが作成できるか試してみましょう。

$ lxc shell c1 (コンテナ内に入りシェルを実行)
root@c1:~# mknod my-dev c 1 5 (デバイスファイルを作成)
mknod: my-dev: Operation not permitted (許可されていない)

失敗しました。デフォルトではデバイスファイルの作成は許可されていませんのでこれは当然の動作です。

それでは、LXDで設定してデバイスファイルの作成を許可して試してみましょう。許可するには、LXDの設定でコンテナに対してsecurity.syscalls.intercept.mknodtrueに設定します。

$ lxc config set c1 security.syscalls.intercept.mknod=true (seccomp notifyでmknodを許可する)
$ lxc config show c1 | grep intercept
  security.syscalls.intercept.mknod: "true" (trueに設定された)

設定されました。設定を反映させるためにコンテナを再起動し、再度コンテナ内でシェルを実行します。

$ lxc restart c1 (コンテナ再起動)
$ lxc shell c1
root@c1:~# mknod my-dev c 1 5 (実行成功)
root@c1:~# ls -l my-dev
total 1
crw-r--r-- 1 root root 1, 5 Dec  7 06:05 my-dev
(デバイスファイルが作成されている)

無事、メジャー番号1、マイナー番号5のデバイスファイル/dev/zeroが作成されています。さらにもうひとつテストで作ってみましょう/dev/random⁠。

root@c1:~# mknod my-dev2 c 1 8 (メジャー番号1、マイナー番号8で作成)
root@c1:~# ls -l my-dev2
crw-r--r-- 1 root root 1, 8 Dec  7 06:08 my-dev2
(デバイスファイルが作成されている)

問題なく作成できました。

shiftfs

次はファイルシステムのマウントを試してみましょう。その前に、この後のマウント操作で使用する機能であるshiftfsについて少しだけ紹介しておきます。

この機能は正式にカーネルにはマージされておらず、おそらくカーネルにマージされる際は別の名前で、今回紹介するshiftfsとは異なる実装となっているかもしれません[4]⁠。Ubuntu 20.04のカーネルではshiftfsのパッチがマージされていて使えるようになっています。

LXDに限らず、ネットワーク越しに取得するコンテナイメージや、自分で作成したコンテナイメージ内のファイルの所有権は、イメージ作成時に設定された所有権が設定されていることが普通でしょう。通常はほとんどのファイルがroot:rootとなっているのではないでしょうか。

しかしユーザ名前空間を使った非特権コンテナの場合、ホストから見たコンテナイメージ内の所有権は、非特権コンテナを起動するユーザ・グループの所有になっていないと、コンテナ内ではnobody:nogroupとなってしまうなど、本来期待する所有権とは異なる設定がされていて使えない状態になります。

そこで、非特権コンテナの場合、コンテナ起動時に再帰的にchownして所有権を期待する設定にする必要があります。

ここで、overlayfsのように重ね合わせのファイルシステムとして、オリジナルのファイルシステムを下層として、コンテナ用のファイルシステムを上層として重ね合わせた上で所有権を調整して、コンテナに提供できるようにしているのがshiftfsです。Ubuntuでは19.04でこのパッチを適用したカーネルが提供されました。

非特権コンテナからファイルシステムをマウントする場合は、この機能を使って所有権を調整する必要があるので、今回LXDでseccomp notifyを使う場合も、snapパッケージで動作するLXDがこの機能を使えるようにする必要があります。デフォルトでは次のように有効化されていません。

$ lxc info | grep shiftfs
    shiftfs: "false"

そこで、まずはLXDでshiftfsが有効になるように設定しましょう。snapパッケージでインストールしたLXDでshiftfsが使えるようにするには次のようにします[5]⁠。

$ sudo snap set lxd shiftfs.enable=true (snapのlxdでshiftfsを有効化)
$ sudo systemctl reload snap.lxd.daemon (設定を反映させるために再起動)
$ lxc info | grep shiftfs
    shiftfs: "true"

これで準備OKです。shiftfsを有効にする前にコンテナを作成していた場合は、ここで一度コンテナを再作成します[6]⁠。

$ lxc delete --force c1
$ lxc launch ubuntu:20.04 c1
Creating c1
Starting c1

ファイルシステムのマウント

準備ができましたので、seccomp notify機能を使ってファイルシステムをマウントしてみましょう。LXDで非特権コンテナ内でmountシステムコールを実行し、マウントができるようになったのは3.19からです。

ここの例では、ホストシステム上にマウントされていないパーティション/dev/sdb1が存在しています。このパーティションはext4でmkfsしています。

$ sudo fdisk -l /dev/sdb | grep sdb1
/dev/sdb1        2048 10485759 10483712   5G 83 Linux

このsdb1はコンテナ内ではデバイスファイルが存在しないので、デフォルトのままではマウントできません。また/dev/sdb1はコンテナ内でデバイスファイルが作成できませんので、LXDで設定してホスト側のデバイスファイルをバインドマウントしておきましょう。

$ lxc config device add c1 sdb1 unix-block path=/dev/sdb1 (コンテナ内に/dev/sdb1を出現させる)
Device sdb1 added to c1
$ lxc restart c1 (設定を反映させるためにコンテナ再起動)

コンテナ内に/dev/sdb1が出現しており、マウントする準備が済みました。

root@c1:~# ls -l /dev/sdb1
brw-rw---- 1 root root 8, 17 Dec  7 14:54 /dev/sdb1

seccomp notify機能を使ってマウントする前に、コンテナ内でマウント操作ができないことを確認しておきます。

$ lxc shell c1
root@c1:~# mount /dev/sdb1 /mnt
mount: /mnt: permission denied.
(コンテナに対してマウントを許可していないので失敗する)

マウント操作は失敗します。

ここで、コンテナに対して次の設定を行い、設定を反映させるためにコンテナを再起動します。

  • マウントができる設定security.syscalls.intercept.mount
  • マウントしたファイルシステムに対してshiftfsを有効にする設定security.syscalls.intercept.mount.shift
  • マウントできるファイルシステムとしてext4を許可する設定security.syscalls.intercept.mount.allowed
$ lxc config set c1 security.syscalls.intercept.mount true
$ lxc config set c1 security.syscalls.intercept.mount.shift true
$ lxc config set c1 security.syscalls.intercept.mount.allowed ext4
$ lxc restart c1

コンテナ内のシェルからマウントしてみましょう。

$ lxc shell c1
root@c1:~# mount /dev/sdb1 /mnt (マウント操作が成功する)
root@c1:~# df -h | grep /mnt
/mnt                                                                 4.9G   20M  4.6G   1% /mnt

マウント操作が成功し、ファイルシステムがマウントできています。

ここでは直接ext4をマウントしていますが、安全のためにコンテナ内にfuse2fsパッケージをインストールし、FUSE(fuse2fs)を使ってマウントすることもできますsecurity.syscalls.intercept.mount.fuse⁠。

まとめ

今回は、非特権コンテナ内でのデバイスファイルの作成とマウント操作を通してseccomp notify機能について紹介しました。

LXDでは、今回紹介したmknodmknodatmountシステムコール以外にもいくつかのシステムコールを許可する設定が追加されています。

seccomp notify機能を使うことで、これまで非特権コンテナ内で実行できなかった操作ができるようになり、非特権コンテナ活用の幅が広がりました。

今回の記事を書くに当たって、udzuraさんにレビューをしていただき、特に実装に近い部分について色々と教えていただきました。ありがとうございました。

参考文献

おすすめ記事

記事・ニュース一覧