前回の連載が掲載されたあと、久々にコンテナの勉強会をオンラインで開催しました。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]。
seccomp
さて、「システムコールが発行されたのか知ることができません」、「必要な検査を行う方法がありません」と書きましたが、実はこのようなシステムコールの発行を検知したり、検査を行う機能はすでにLinuxカーネルには実装されています。
これはseccompと呼ばれ、タスクが発行するシステムコールをフィルタリングする機能です。
seccompは2005年、2.6.12カーネルではじめて導入された機能です。この時点でのseccompは、決められたごく一部のシステムコールのみの実行を許可するだけの機能でした。
その後、3.5カーネルで柔軟な設定ができるseccomp mode 2が導入され、システムコールごとに制限ができるようになりました。また、呼ばれたシステムコールの種類をチェックするだけでなく、システムコールに与えられた引数も検査した上でシステムコールを実行するかどうか決定できます。
フィルタリングの指定は、ホワイトリスト的な指定、ブラックリスト的な指定のどちらでも指定できます。つまり、すべてのシステムコールの実行を制限した上で許可したいシステムコールを指定すできますし、逆にすべてのシステムコールの実行を許可した上で制限したいシステムコールを指定することもできます。
図2のように、プロセスを実行する際に、あらかじめシステムコールの実行に関するポリシーを定義しておきます。そして、実際にシステムコールが発行されると、ポリシーに従ってシステムコールを実行するかどうかが判断されます。指定したポリシーは子プロセスにも引き継がれます。
実行が許可されていないシステムコールが実行された場合の動作として、すぐにプロセスを終了させる、設定したエラー(番号)を返すなど、いくつか選択できます。システムコールの実行が失敗しても、システムコールを呼び出したプロセスの実行を中断せずに処理を続けることはできますが、システムコールは実行されません。
seccomp notify
このように、seccompは特定のシステムコール呼び出しを検出し、インターセプトできますので、先に示した問題を解決するのに一番近いところにいる機能であることは間違いありません。
しかし、コンテナ内のプロセス内でシステムコールが呼ばれたことを別のプロセスであるコンテナマネージャが知ることはできません。
また、seccompを使ってシステムコールがあたかも成功したように見せかけることはできます。しかし、いずれにせよ実際にはシステムコールは実行されません。つまりシステムコールの実行を検出し、その実行を許可するか拒否するか以外には選択肢がありませんでした。
そこで、ここまで説明した問題を解決できるようにseccompの機能を拡張したのが、今回紹介する"Seccomp notify"機能です[2]。この機能は5.0カーネルで導入されました。
この機能を使うには、プロセスに設定するseccompフィルタにSECCOMP_RET_USER_NOTIF
というフラグを設定します(図3の①)。するとフィルタがロードされたあと、図3の②のようにカーネルが呼び出し元のタスクにファイルディスクリプタ(fd)を返します。
呼び出し元のタスク自体は、この返されたfdを使って何かをするわけではありません。このfdをコンテナマネージャなど、他のタスクに渡します(図3の③)。
ファイルディスクリプタを渡されたコンテナマネージャは、このファイルディスクリプタに対してioctl
を呼び出し、必要なデータが格納されるのを待ちます。
そして、フィルタに設定したシステムコールが実行されると、カーネルは図4の②のようにこのファイルディスクリプタへ通知を送ります。そしてシステムコールの実行はブロックされます。
コンテナマネージャは②で送られた通知を読み取ります。この通知からは、呼び出されたシステムコール、システムコールを呼び出したプロセスのPID、システムコールが実行されたアーキテクチャ、システムコールの引数などがわかります。
ここでコンテナマネージャ内で必要な検査を行い、同じファイルディスクリプタへ検査結果を返します(図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では、まずはmknod
とmknodat
システムコールが使えるようになり、デバイスファイルが作成できるようになりました。
LXDが依存しているLXCでは3.2.1から、libseccompは2.5.0からseccomp notify機能が使えます。LXDはLXC、libseccompを必要としていますので、これらの新しいライブラリが必要です。
LXDでmknod
、mknodat
が使えるようになったと言っても任意のデバイスファイルが作成できるわけではありません。許可されているデバイスは先に紹介した/dev/null
や/dev/zero
などの一部のデバイスだけです。詳細は公式ドキュメント(日本語訳)に記載があります。
それでは、デバイスファイルを作成してseccomp notify機能を試してみましょう。今回の実行例はUbuntu 20.04.1にsnapでインストールしたLXD 4.8という環境で実行しています。snapであれば前述のようなライブラリの依存関係を考えることなく、seccomp notify機能が使える形で作成されていますので安心です。なお、LXDに関してはstableリリースの4.0シリーズでもseccomp notify機能が使えます。
まずはコンテナを作成します。そしてこのコンテナが一般ユーザで起動していることを確認します。
それではコンテナ内に入ってデバイスファイルが作成できるか試してみましょう。
失敗しました。デフォルトではデバイスファイルの作成は許可されていませんのでこれは当然の動作です。
それでは、LXDで設定してデバイスファイルの作成を許可して試してみましょう。許可するには、LXDの設定でコンテナに対してsecurity.syscalls.intercept.mknod
をtrue
に設定します。
設定されました。設定を反映させるためにコンテナを再起動し、再度コンテナ内でシェルを実行します。
無事、メジャー番号1、マイナー番号5のデバイスファイル(/dev/zero
)が作成されています。さらにもうひとつテストで作ってみましょう(/dev/random
)。
問題なく作成できました。
shiftfs
次はファイルシステムのマウントを試してみましょう。その前に、この後のマウント操作で使用する機能であるshiftfsについて少しだけ紹介しておきます。
この機能は正式にカーネルにはマージされておらず、おそらくカーネルにマージされる際は別の名前で、今回紹介するshiftfsとは異なる実装となっているかもしれません[4]。Ubuntu 20.04のカーネルではshiftfsのパッチがマージされていて使えるようになっています。
LXDに限らず、ネットワーク越しに取得するコンテナイメージや、自分で作成したコンテナイメージ内のファイルの所有権は、イメージ作成時に設定された所有権が設定されていることが普通でしょう。通常はほとんどのファイルがroot:root
となっているのではないでしょうか。
しかしユーザ名前空間を使った非特権コンテナの場合、ホストから見たコンテナイメージ内の所有権は、非特権コンテナを起動するユーザ・グループの所有になっていないと、コンテナ内ではnobody:nogroup
となってしまうなど、本来期待する所有権とは異なる設定がされていて使えない状態になります。
そこで、非特権コンテナの場合、コンテナ起動時に再帰的にchown
して所有権を期待する設定にする必要があります。
ここで、overlayfsのように重ね合わせのファイルシステムとして、オリジナルのファイルシステムを下層として、コンテナ用のファイルシステムを上層として重ね合わせた上で所有権を調整して、コンテナに提供できるようにしているのがshiftfsです。Ubuntuでは19.04でこのパッチを適用したカーネルが提供されました。
非特権コンテナからファイルシステムをマウントする場合は、この機能を使って所有権を調整する必要があるので、今回LXDでseccomp notifyを使う場合も、snapパッケージで動作するLXDがこの機能を使えるようにする必要があります。デフォルトでは次のように有効化されていません。
そこで、まずはLXDでshiftfsが有効になるように設定しましょう。snapパッケージでインストールしたLXDでshiftfsが使えるようにするには次のようにします[5]。
これで準備OKです。shiftfsを有効にする前にコンテナを作成していた場合は、ここで一度コンテナを再作成します[6]。
ファイルシステムのマウント
準備ができましたので、seccomp notify機能を使ってファイルシステムをマウントしてみましょう。LXDで非特権コンテナ内でmount
システムコールを実行し、マウントができるようになったのは3.19からです。
ここの例では、ホストシステム上にマウントされていないパーティション/dev/sdb1
が存在しています。このパーティションはext4でmkfsしています。
このsdb1
はコンテナ内ではデバイスファイルが存在しないので、デフォルトのままではマウントできません。また/dev/sdb1
はコンテナ内でデバイスファイルが作成できませんので、LXDで設定してホスト側のデバイスファイルをバインドマウントしておきましょう。
コンテナ内に/dev/sdb1
が出現しており、マウントする準備が済みました。
seccomp notify機能を使ってマウントする前に、コンテナ内でマウント操作ができないことを確認しておきます。
マウント操作は失敗します。
ここで、コンテナに対して次の設定を行い、設定を反映させるためにコンテナを再起動します。
- マウントができる設定(
security.syscalls.intercept.mount
)
- マウントしたファイルシステムに対してshiftfsを有効にする設定(
security.syscalls.intercept.mount.shift
)
- マウントできるファイルシステムとしてext4を許可する設定(
security.syscalls.intercept.mount.allowed
)
コンテナ内のシェルからマウントしてみましょう。
マウント操作が成功し、ファイルシステムがマウントできています。
ここでは直接ext4をマウントしていますが、安全のためにコンテナ内にfuse2fsパッケージをインストールし、FUSE(fuse2fs)を使ってマウントすることもできます(security.syscalls.intercept.mount.fuse
)。
まとめ
今回は、非特権コンテナ内でのデバイスファイルの作成とマウント操作を通してseccomp notify機能について紹介しました。
LXDでは、今回紹介したmknod
、mknodat
、mount
システムコール以外にもいくつかのシステムコールを許可する設定が追加されています。
seccomp notify機能を使うことで、これまで非特権コンテナ内で実行できなかった操作ができるようになり、非特権コンテナ活用の幅が広がりました。
今回の記事を書くに当たって、udzuraさんにレビューをしていただき、特に実装に近い部分について色々と教えていただきました。ありがとうございました。
参考文献