Ubuntu Weekly Recipe

第686回Bubblewrap/bwrapを使って管理者権限なしで非特権コンテナーを作る

LXDにしろDockerにしろsystemd-nspawnにしろ、コンテナーを作るには直接的であれ間接的であれ管理者権限が必要です。しかしながら、これらのコンテナー技術の基になっているLinuxカーネルの名前空間は、必ずしも管理者権限で作らなければいけないわけではありません。今回は、ユーザー権限で非特権コンテナーを構築できるBubblewrap(bwrapコマンド)を紹介します。

Bubblewrapとunshare

LXDやDockerといったコンテナー管理ツールは、管理者権限でデーモンを立ち上げ、クライアントがそのデーモンと通信することでコンテナーインスタンスを構築しています。また第491回のいまから『あえて』systemdのコンテナ機能を使ってみるで紹介されているsystemd-nspawnもコンテナーインスタンス作成時にsudoコマンドを使っています。

しかしながらRed Hatが開発し、RHEL系を問わず広く使われているコンテナーエンジンであるpodman「デーモンレス」「ルートレス」としても使えることを謳っています。つまりコンテナーを使うだけなら、何か管理者権限で動くデーモンも必要ありませんし、ユーザー権限でできるのです。

Bubblewrapは、そんなユーザー権限でコンテナーインスタンス(サンドボックス)を作るツールのひとつです。Flatpakがxdg-appという名前だった頃に、アプリケーションをサンドボックス化するにあたって作られたツールであり、GNOMEで開発されていたlinux-user-chrootが元になっています。つまりどちらかと言うとデスクトップアプリをサンドボックスの中で動かすために作られたツールなのです[1]⁠。

実はデスクトップ版のUbuntuであれば、Ubuntu 18.04 LTSから自動的にインストールされた状態になっています。つまり追加で何かインストールしなくても、最初からBubblewrapが使えるのです。このBubblewrapはたとえばGNOMEファイル(Nautilus)「/usr/share/thumbnailers/」以下のスクリプトを使って、サムネイルを生成する際に使っていたりします。

同じようにユーザー権限でコンテナーを構築するツールとしてunshareコマンドが存在します。こちらはutil-linuxパッケージに所属しているため、ほぼすべてのUbuntuにインストールされていることでしょう。こちらはbubblewrapに比べるとよりシンプルなツールで、どちらかと言うとunshare(2)システムコールのラッパーという位置づけです。ただし基本的にやっていることは同じなので、unshareコマンドを用いて同等の機能を実現することは原理的には可能となります。

Bubblewrapの基本的な使い方

デスクトップ版のUbuntuならBubblewrapは最初から入っているはずです。サーバー版のようにまだインストールされていなかったら、次のようにインストールしてください。

$ sudo apt install bubblewrap

ソフトウェア名は「Bubblewrap」ですが、ややこしいことにコマンド名はbwrapです。

$ ls -l $(which bwrap)
-rwxr-xr-x 1 root root 68032  1月  3  2021 /usr/bin/bwrap
$ getcap $(which bwrap)

このように、setuidもなされていないことから、ユーザー権限で動くことがわかります。

ちなみにUbuntuのbwrapは以前からsetuidが外されていましたが、Debianは先日リリースされたDebian 11 bullseyeより前はsetuidがついていました。これはサポートしていたカーネルが古く、非特権ユーザーによる名前空間の利用に制約があったためです。また、次のsysctlが1になっている必要もあります。

$ sysctl kernel.unprivileged_userns_clone
kernel.unprivileged_userns_clone = 1

実際にbashを別のユーザー名前空間の別の存在しないUIDで動かしてみましょう。

$ id 2000
id: `2000': no such user

$ bwrap --ro-bind / / --dev /dev --unshare-user --uid 2000 --gid 2000 bash
私は名前がありません!@nuc:~$ id
uid=2000 gid=2000 groups=2000,65534(nogroup)

--ro-bind / /でホストのルートファイルシステムをコンテナー内部のルートファイルシステムに読み込み専用でバインドしています。読み書きできるようにしたい場合は--bindを使います。

--dev /devで新しいdevtmpfsをマウントしています。新規のdevtmpfsをマウントしているためコンテナーから見ると、ホストの/dev/と異なり、基本的なデバイスしか見えません。

私は名前がありません!@nuc:~$ ls /dev/
console  core  fd  full  null  ptmx  pts  random  shm  stderr  stdin  stdout  tty  urandom  zero

もしホストのdevtmpfsをコンテナーから見えるようにして、各種デバイスを使えるようにしたい場合は--dev-bind /dev /devのように指定します。ただしデバイスファイルにアクセスできるようにするためには、ホスト・コンテナーともにUID/GIDについて慎重に検討する必要があります。たとえば--unshare-userで名前空間をホストから隔離します。非特権ユーザーで実行した場合は、コンテナー内部のUID/GIDはホストから見るとそのUID/GIDとして見えます。

--uid 2000 --gid 2000でコンテナーの中のUID/GIDをそれぞれ2000にしています。今回はルートファイルシステムがホストと同じ状態で、なおかつそのホストにはUIDが2000となるユーザーが存在しないため、bashのプロンプトには「私は名前がありません!」と表示されてしまっています。

最後に実行するコマンドとして「bash」を指定しています。試しに起動したbashのPIDをコンテナーの中とホストからそれぞれ見てみましょう。

私は名前がありません!@nuc:~$ ps --pid $$ -f n
     UID     PID    PPID  C STIME TTY      STAT   TIME CMD
    2000 3287247 3287246  0 19:37 ?        S      0:00 bash

$ ps --pid 3287247 -f n
     UID     PID    PPID  C STIME TTY      STAT   TIME CMD
    1000 3287247 3287246  0 19:37 pts/12   S+     0:00 bash

前者はUID=2000のプロセスになっていますが、ホストから見るとbwrapを実行したUID=1000として見えていることがわかります。

今回は読み込み専用でバインドしているため、ルートファイルシステムには書き込みできませんが、たとえば--bind / /オプションにしていた場合は、⁠bwrapを実行したユーザーがアクセス可能な領域ならコンテナー内部からも書き込める」ということになります。

bwrapではユーザー名前空間以外にも次のようなオプションで各名前空間を隔離可能です。

--unshare-user
ユーザー名前空間を作成し、UID/GIDをホストから隔離する。
--unshare-user-try
可能ならユーザー名前空間を作成するものの、できなかったら無視する。
--unshare-ipc
IPC(プロセス間通信)名前空間を作成し、共有メモリーやセマフォなどをホストから隔離する。
--unshare-pid
PID名前空間を作成し、プロセス情報をホストから隔離する。
--unshare-net
ネットワーク名前空間を作成し、ネットワークインターフェース等をホストから隔離する。
--unshare-uts
UTS(Unixt Time Sharing)名前空間を作成し、ホスト名やドメイン名をホストから隔離する。
--unshare-cgroup
cgroup名前空間を作成し、cgroup機能をホストから隔離する。
--unshare-cgroup-try
可能ならcgroup名前空間を作成するものの、できなかったら無視する。
--unshare-all
ユーザー・IPC・PID・ネットワーク・UTS・cgroup名前空間を作成する。

必要に応じて隔離度を変えると良いでしょう。

たとえば先ほど例だとユーザー名前空間だけ隔離していたため、コンテナーの中からもホストのネットワークインターフェースが見える状態でした。

私は名前がありません!@nuc:~$ ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp5s0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc mq state DOWN mode DEFAULT group default qlen 1000
    link/ether d4:5d:df:1d:f8:93 brd ff:ff:ff:ff:ff:ff
3: eno1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    link/ether d4:5d:df:1d:f8:92 brd ff:ff:ff:ff:ff:ff
    altname enp0s31f6
(以下略)

これが--unshare-netを指定して起動すると、ループバックインターフェースしか見えなくなります。

私は名前がありません!@nuc:~$ exit
exit
shibata@nuc:~$ bwrap --ro-bind / / --dev /dev --unshare-user --unshare-net --uid 2000 --gid 2000 bash

私は名前がありません!@nuc:~$ ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

ちなみに--bind-ro--bindはディレクトリ単位で指定できます。よって「/usr/lib」以下だけホストと同じで、他は別のシステム・ディレクトリーみたいな指定や、/run以下だけ書き込みできるといった使い方も可能です。隔離用途だけでなく、ちょっとしたデバッグにも使えるでしょう。

ルートファイルシステムごとコンテナー化する

最後にbwrapコマンドを使って、ユーザー権限だけで特定のUbuntuの隔離環境を作ってみましょう。これと前述の--bindを組み合わせれば、管理者権限がない環境でもユーザー権限だけで動作確認用のルートファイルシステムを作り、その中でコマンドを動かしてみるといった手順が可能になります[2]⁠。

まずはベースとなるルートファイルシステムをダウンロードしておきます。wgetにしろcurlにしろ方法はなんでもかまいません。LTS向けならUbuntu Baseを使うとサイズを最小にできます。ただし実際のUbuntu環境に合わせるためにはそれなりの追加パッケージが必要です。たとえばvimやnanoはおろか、edもインストールされていないため、パッケージのインストールをせずにファイルを編集するにはsedとawkを駆使する必要があります。

その点、Ubuntu Cloud Imagesにあるクラウドイメージなら、各種クラウドサービスの最大公約数的なパッケージ構成になっているため、ほぼサーバー版のUbuntuと同じです。いろいろなイメージが用意されていますが(リリース名⁠⁠-server-cloudimg-⁠アーキテクチャー⁠⁠-root.tar.xzを選んでください。しかしながらUbuntu Baseが27MiB程度なのに対して、Cloud Imagesは327MiBぐらいとサイズは10倍以上異なります。

ここではUbuntu Baseをダウンロードしてbwrapで切り替えてみましょう。

$ wget http://cdimage.ubuntu.com/ubuntu-base/releases/20.04/release/ubuntu-base-20.04.3-base-amd64.tar.gz
$ mkdir rootfs
$ tar xvf ubuntu-base-20.04.3-base-amd64.tar.gz -C rootfs
$ echo 'APT::Sandbox::User "root";' > rootfs/etc/apt/apt.conf.d/90run-as-root

最後のコマンドだけ説明が必要です。最近のAptはパッケージのインターネットからのダウンロードや署名の検証などを、管理者権限ではなく_aptユーザーで行うようになっています。つまりaptコマンドの中で、このユーザーに変わらなくてはなりません。しかしながら、今回はできるだけシンプルにbwrapを使うようにする都合上、単に「コンテナーの中のrootを、ホストの非特権ユーザー」にのみマッピングしています。よってここでは_aptユーザーへのマッピングを用意したり、ダウンロードしたルートファイルシステムのパーミッションを正しく設定することは諦めて、Apt側の設定で「常に管理者権限で実行する」ように設定しました。

これでルートファイルシステムの作成は完了です。最後にそのルートファイルシステムにbwrapで「ログイン」してしまいましょう。

$ bwrap --bind rootfs/ / --ro-bind /etc/resolv.conf /etc/resolv.conf \
    --dev /dev --chdir / --uid 0 --gid 0 --unshare-pid --unshare-user /bin/bash

root@nuc:/# cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.3 LTS (Focal Fossa)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 20.04.3 LTS"
VERSION_ID="20.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=focal
UBUNTU_CODENAME=focal

root@nuc:/# apt update
Hit:1 http://security.ubuntu.com/ubuntu focal-security InRelease
(以下略)

今回、ネットワークについてはホストのそれをそのまま相乗りするため、--unshare-netは指定していません。ただしDNSによる名前解決は必要になるため--ro-bind /etc/resolv.conf /etc/resolv.confでホストのresolv設定をそのままコンテナーからも見えるようにしました。

これで管理者権限を取得することなく、コンテナーの中でパッケージのインストール等が使えるようになります。どの操作も基本的にホストから見ると、非特権ユーザーとして動いています。もちろん任意のデバイスファイルのアクセスやカーネルモジュールのロードなど、ホストの特権が必要になる処理は実行できません。もしろんsystemdも動いていないため、サービスの起動も一苦労です。このようにできることは限られてはしまいますが、できるだけ余計なパッケージをインストールせずに環境を整えるという意味では役に立つことでしょう。

ちなみに今回のコンテナーは隔離度が低く「セキュア」ではありません。一般的に非特権コンテナーと言うときはAppArmorなどの矯正アクセス制御やseccomp等を駆使して、コンテナーからできることを限定しています。まともにコンテナーとして運用したいなら、既存のシステムを使うようにしましょう。

おすすめ記事

記事・ニュース一覧