コンテナをrootで動かすことの実際とSecure by Defaultにする改善案を紹介する (original) (raw)

こんにちは。株式会社ユーザベース スピーダ事業でSREをしている八代 (@yashirook) です。

先日、社内勉強会でコンテナをrootで動かすことについて話したのですが、そこで気づきがあった人もいたようなので、テックブログにも記事を書いてみることにしました。

はじめに

コンテナ技術を利用して開発している人であれば、コンテナをroot以外のユーザーで実行することがベストプラクティスとされていることを耳にしたことがある方が多いと思います。

弊社のテックブログでも、以下の記事が投稿されています。

tech.uzabase.com

DockerやKubernetesなどを利用してコンテナをrootユーザーで実行すると、該当のコンテナはホスト上のroot相当の権限を持って実行されてしまうと理解される場合がありますが、実際にはホスト上のrootと完全に同じ権限を持っているわけではないことが多いです

この記事を通して、コンテナプロセスをrootユーザーで実行することについて理解を深めてもらいたいと考えています。

本記事のスタンスとしては、セキュリティの観点ではrootユーザーでプロセスを実行することは避けるべきという結論は変わりません

その前提の上で、コンテナワークロードを動作させる基盤管理者や、アプリケーション開発者にとって、セキュリティ改善を考える上で対応の優先度や、守るべきラインを判断する上で、有用なインプットになれば幸いです。

コンテナはホスト上のプロセス

まず、コンテナ技術を利用した仮想化の特徴をおさらいします。

コンテナでは、カーネルの機能を組み合わせてプロセスの隔離を実現しており、同じホスト上で動作するコンテナはカーネルを共有しています

これを一度確認してみましょう。

なお、本記事では以下の環境でデモを行います。

OS: ubuntu-24-04 カーネル: 6.8.0-41-generic Docker Engine: 24.0.7

コンテナプロセスの確認

dockerでsleepコマンドを実行し、そのプロセスの状態を確認してみます。

ubuntu@ubuntu-24-04:~$ sudo docker run -it --rm ubuntu sleep 20240912

ホストの別セッション

ubuntu@ubuntu-24-04:~$ ps auxf | grep sleep -a1 root 25938 0.1 0.3 1237700 13056 ? Sl 05:25 0:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 81ddb5982066bcba06410902aad42c19d94eabb33d4ad4d246eac3ac6eb5087e -address /run/containerd/containerd.sock root 25981 0.0 0.0 2268 1024 pts/0 Ss+ 05:25 0:00 _ sleep 20240912

psコマンドの実行結果からは、PIDをルートとしたプロセスのツリー構造を取得することができ、sleepプロセスに関する部分を抜粋しています。

実行結果から、/usr/bin/containerd-shim-runc-v2のプログラムからsleepプロセスが起動されていることがわかりました。

sleepプロセスのユーザーはrootであり、デフォルトのubuntuイメージの実行ユーザーで実行されています。

ここから、コンテナ上で実行したsleepプログラムがホスト上のプロセスとして動作していることが確認できました。

コンテナプロセスの隔離に用いられる技術

コンテナのプロセスは、多数のカーネルの機能を組み合わせて実現されています。

代表的なカーネルの機能と、プロセスの隔離において果たしている役割を簡単にまとめます。

カーネルの機能 コンテナプロセスの隔離における役割
cgroup ホスト上のリソース(CPU, メモリ, デバイスなど)の使用に関する制限
namespace プロセスの名前空間の分離
chroot プロセスが認識するルートファイルシステムの変更
capability プロセスが発行できるシステムコール
AppArmor プロセスに対するリソースアクセスなど(ファイルアクセス、ネットワーク操作、Linux capabilitiesなど様々)を制限
seccomp システムコールの制限

個々の機能についての説明は本記事の範疇を超えるので説明しませんが、多数のカーネル機能が組み合わせてコンテナプロセスの隔離が実現されていることはイメージいただけるのではないかと思います。

詳しくは、記事末尾に参考にした参考文献を記載しているので、興味があれば調べてみてください。

sleepプロセスがrootユーザーで実行されていることを確認しました。

次は、rootユーザーで起動したのコンテナプロセスが本当にrootなのかを検証していきましょう。

capabilityの確認

ホストで実行したプロセスとの差分を検証するため、capabilityに着目して検証を行います。

capshコマンドを使うと、実行したユーザーが所有するcapabilityを確認することができます。(デフォルトのubuntuイメージにはcapshコマンドがインストールされていないので、aptでlibcap2-binパッケージをインストールすると使えます)

それでは、コンテナと、ホストでそれぞれrootユーザーで実行しているbashから、プロセスが所有するcapabilityを確認してみましょう。

ubuntu@ubuntu-24-04:~$ sudo docker run -it --rm ubuntu bash root@52314feede11:/# capsh --print Current: cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap=ep Bounding set =cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

ubuntu@ubuntu-24-04:~$ sudo capsh --print Current: =ep Bounding set =cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read,cap_perfmon,cap_bpf,cap_checkpoint_restore

それぞれで出力内容が異なっていることがわかります。

Current、Bounding setはそれぞれ以下を示します(参考)。

対象 説明
Current 現在のプロセスにおいて有効化されているCapabilityの集合。epはBounding setに記載されているCapabilityの全てが有効化されていることを示す。
Bounding set プロセスが獲得可能なCapabilityの集合。後から追加することは不可。

上記の説明を踏まえると、コンテナのbashプロセスで有効化されているCapabilityはホスト上のプロセスより少なくなっており、実行可能なシステムコールが制限されていることが読み取れます。

例えば、ホストOSの終了や再起動を行うshutdownコマンドを実行するためには、CAP_SYS_BOOTのcapabilityが必要になりますが、コンテナで起動したプロセスにはこのcapabilityは付与されていません。

これを確かめるため、コンテナプロセスからshutdownコマンドを実行してみます。

コンテナプロセスからshutdownコマンドを実行する

shutdownコマンドを実行するためには、PID 1 のプロセスがsystemdである必要があるので、そのままコマンドをインストールしても実行することができません。

このため、一度ホストのボリュームをマウントし、ルートファイルシステムをホストのルートに設定した上で実行を試みます。(ホストのルートファイルシステムをマウントするのは大きなセキュリティリスクを含むので、必ずローカルの検証環境で実行してください

ubuntu@ubuntu-24-04:~$ sudo docker run -it -v /:/hostvolume --rm ubuntu bash [sudo] password for ubuntu: root@275101c19865:/# cd /hostvolume/

// ルートファイルシステムを変更 root@275101c19865:/hostvolume# chroot .

shutdown

Failed to set wall message, ignoring: Access denied Failed to schedule shutdown: Transport endpoint is not connected

shutdownコマンドが失敗することが確認できました。これは、プロセスがCAP_SYS_BOOTのcapabilityを持っていないためです。

capabilityを制限しているもの

ここで気になるのが、何がcapabilityを制限しているのかということだと思います。

結論から言うと、Dockerがデフォルトで適用しているseccompプロファイルが適用されているためです。

ドキュメントには拒否されるシステムコールが記載されています。

他にも、デフォルトのAppArmorが有効化されており、システムコール以外にも制限を加えてくれています。

seccompsやAppArmorのプロファイルが適用されているため、ホストで起動したrootプロセスと完全に同一の権限を持たないようになっています。

AppArmor, seccompの制限を無視する

コンテナプロセスを制限してくれているAppArmorやseccompsですが、これらは残念ながら--privilegedオプションによって簡単に突破できてしまいます。

--privilegedオプションを付与して起動したコンテナのcapabilityを確認してみましょう。(ホストのルートファイルシステムのマウント以上に大きなセキュリティリスクを含むので、こちらも必ずローカルの検証環境で実行してください

コンテナでの実行結果

ubuntu@ubuntu-24-04:~$ sudo docker run -it -v /:/hostvolume --privileged --rm ubuntu-techforum-demo bash [sudo] password for ubuntu: root@169cd74ccd9e:/# capsh --print Current: =ep Bounding set =cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read,cap_perfmon,cap_bpf,cap_checkpoint_restore // 省略

ホストでの実行結果

ubuntu@ubuntu-24-04:~$ sudo capsh --print Current: =ep Bounding set =cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read,cap_perfmon,cap_bpf,cap_checkpoint_restore // 省略

上記の結果で確認できるプロセスがもつcapabilityは、ホスト上で実行した結果と一致してしまいます。

それでは、先ほどは失敗したshutdownコマンドを同様の手順で実行してみます。

root@169cd74ccd9e:/# cd /hostvolume/ root@169cd74ccd9e:/hostvolume# chroot .

shutdown

Broadcast message from root@ubuntu-24-04 on pts/0 (Thu 2024-09-12 07:21:50 UTC):

The system will power off at Thu 2024-09-12 07:22:50 UTC!

Shutdown scheduled for Thu 2024-09-12 07:22:50 UTC, use 'shutdown -c' to cancel.

shutdownコマンドがコンテナから実行できてしまっています。(このままだとシャットダウンされてしまうので、shutdown -cでキャンセルしておきましょう)

このように、--privilegedオプションをつけるだけで、簡単にホスト上のrootと同一の権限を持ててしまいます。

非常に簡単に強い権限を持ててしまうため、安易に--privilegedオプションを利用するのは危険であることがイメージできたのではないかと思います。

コンテナ実行基盤管理者の目線では、このようなオプションが容易に付与できてしまう状態は阻止したいように思いますね。

コンテナエスケープ

先述したように、コンテナプロセスがホスト上のリソースにアクセスしたり、capabilityなどの権限を獲得したりしてしまうことをコンテナエスケープと呼びます。

先ほどは、ホスト上のルートファイルシステムをマウントすることで、コンテナエスケープができてしまいました。

コンテナエスケープは、先ほどのホストのファイルシステムのマウントのような不適切な設定や、コンテナランタイムやカーネルの脆弱性を悪用することで案外容易にできてしまう場合があります。

特に、--privilegedオプションが付与されている場合は、様々な方法でエスケープし、任意のコードを実行することができてしまうので、注意しましょう。

不適切な設定によりコンテナエスケープを実現する方法はContainer Security Bookで様々なものを紹介してくれているので、興味がある方は見てみてください。

もちろん、--privilegedオプションが付与されていなくても、コンテナエスケープを発生させうるカーネルやコンテナランタイムの脆弱性も存在します。

一定頻度で報告されており、最小権限を付与する必要性を再認識させられました。

検証結果を受けて

ここまでの検証結果を見て、コンテナをrootで実行することのリスクや、コンテナランタイムが守ってくれている部分(AppArmor, seccompなど)とその回避方法(--privilegedオプション)をイメージしてもらえたのではないかと思います。

個々の開発者がコンテナをrootで実行しないことや、その他不適切な設定を入れないように努めることは重要だと考える一方で、詳細を把握して適切に設定する認知負荷が高いことも考慮する必要があると思います。

コンテナ実行基盤としてSecure by Defaultになるような設定を入れていくこともやはり重要なので、最後にいくつかセキュリティリスクを軽減するための仕組みを最後に紹介したいと思います。

弊社ではコンテナ実行基盤としてKubernetesを利用しているので、その前提でいくつか紹介をしていきたいと思います。

不適切な設定を防ぐ

Kubernetesでは、DeploymentなどのマニフェストをAPIサーバーに対してapplyすることでコンテナを起動するための情報を渡します。

Kubernetes v1.31.0では、ValidatingAdmissionWebhookValidatingAdmissionPolicyなどのリソースを利用することでapplyされてきた内容を検証することができます。

これまではValidatingAdmissionWebhookの選択肢が主で、Webhookを受け取るアプリケーション(セルフホスト, Open Policy Agentなど)で検証する必要があったのですが、**ValidatingAdmissionPolicyの登場により、Common Expression Languageを利用することでKubernetesリソースだけでお手軽に検証ができるようになりました**。

この機能については個人的に関心があったので、検証を行った記事のリンクを記載しておきます。Common Expression Language(CEL)でKubernetesのマニフェストのValidationを行う

今回紹介した--privileged相当の設定も検証して弾くことができるので、採用を検討するのも良いと思います。

user namespaceを利用したuidの分離

コンテナではnamespaceの機能を利用してプロセスの隔離を行なっていることを説明しました。

DockerやKubernetesではデフォルトで有効化されていませんが、user namespaceの隔離も可能です。

これを有効化することで、コンテナプロセス自身は自分をrootユーザーとして認識している一方で、ホスト上でのユーザーはroot以外の非特権ユーザーで実行される状態を実現することができます

こちらもDockerで動作を確認してみます。

この機能を有効化するためには、Dockerデーモンの設定を追加する必要があるので、/etc/docker/daemon.jsonに以下の内容を追加します。(すでに他の設定が入っている場合は、userns-remapの部分を追加してください)

{ "userns-remap": "default" }

設定ファイルを変更したら、Dockerデーモンの再起動を実施します。

ubuntu@ubuntu-24-04:~$ sudo systemctl restart docker

この状態で、コンテナを起動してユーザーを確認します。

ubuntu@ubuntu-24-04:~$ sudo docker run -it --rm ubuntu-techforum-demo bash [sudo] password for ubuntu:

root@5c7423d4af50:/# id uid=0(root) gid=0(root) groups=0(root)

上記から、コンテナプロセスとしては、自身をrootとして認識しています。

ここで、ホスト上でプロセスのユーザーを確認します。

プロセスを特定するため、sleep 20240924を実行しておいた上で、ホストで以下を実行します。

ubuntu@ubuntu-24-04:~$ ps auxf | grep -a2 sleep // 省略 root 32624 0.2 0.3 1237444 13112 ? Sl 08:34 0:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 5c7423d4af50f29d872d2eda9a4c6aedc0c0dc86484ce150e1b12b7e0bf6d24a -address /run/containerd/containerd.sock 165536 32643 0.0 0.0 4296 3328 pts/0 Ss 08:34 0:00 _ bash 165536 32691 0.0 0.0 2268 1152 pts/0 S+ 08:40 0:00 _ sleep 20240912

上記から、bashおよびsleepのコマンドが、uid 165536となっており、非rootユーザーで実行されていることが確認できました。

つまり、コンテナはrootで実行しているつもりですが、ホスト上ではrootユーザーのパーミッションを必要とするリソースへのアクセスができないようになります。

同じコンテナが動作するにしても、セキュリティ的によりセキュアな状態に近づきます。

一度にrootで実行されているアプリケーションを改善することが難しい場合には、活用することも選択肢になるのではないかと思います。

Kubernetesにおいても同様の機能を利用することができ、v1.30でGAしています。

まとめ

コンテナをrootで実行したときにどのようなリスクがあるのかをデモを通じて紹介しました。

AppArmorやseccompなどにより、ホスト上のrootと比較して権限が制限されている部分もありつつ、それが--privilegedオプションで簡単に回避できてしまうことを確認できました。

コンテナのデプロイをする立場としては、セキュリティリスクを認識して、不要な権限を付与しないことの重要性に言及しました。

また、コンテナの実行基盤の管理をする立場としては、Secure by Defaultな状態に近づけるためにできる対策を一部紹介しました。

今回紹介した対策以外にも、rootless Dockerや、AppArmor, seccompのプロファイル適用、イメージの安全性の担保など、様々な対策をとることができます。

コンテナの仕組みを学ぶほど、様々なレイヤで多層的に防御することが重要なことを痛感させられました

この記事をきっかけに、何かの気づきになったら嬉しいです。

参考文献