一般ユーザーの権限で実行できるコンテナのことを rootless コンテナと言います. Rootless コンテナを使うと, コンテナ内では root ユーザーとほぼ同じ権限を持つ一方で, コンテナの外では一般ユーザーの権限しか持たないようなプロセスを実行することができます. Rootless コンテナは, user namespace の機能を使用して実装します. User namespace とは, user ID, group ID, capabilities などのセキュリティに関連する属性を管理する namespace です.
clone(2)
を使って新しい user namespace 内に子プロセスを作成することができます. clone
には次の 4 つの引数を渡します.
fn
: 子プロセスのエントリーポイントとなる関数.void
のポインタを受け取り,int
を返す必要があります.stack
: 子プロセスがスタックとして使うメモリ領域の底を指すポインタ.flags
: 挙動を変更するフラグ.arg
:fn
に渡される引数の配列.
新しい user namespace 内に子プロセスを作成するテンプレートコードは以下のように書けます.
#define _GNU_SOURCE
#include <sched.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#define CHILD_STACK_SIZE (1024 * 1024) // 1Mib
char child_stack[CHILD_STACK_SIZE];
int child_fn(void *args)
{
return 0;
}
int main()
{
int flags = CLONE_NEWUSER | SIGCHLD;
pid_t pid = clone(
child_fn,
child_stack + CHILD_STACK_SIZE,
flags,
NULL
);
// wait for the child to finish
int status;
waitpid(pid, &status, 0);
// return the same exit status as the child
return WEXISTATUS(status);
}
CLONE_NEWUSER
が新しい user namespace で子プロセスを実行するためのフラグです. 子プロセス終了時に SIGCHLD
を親プロセスが受け取れるように, SIGCHLD
フラグも設定しています.
本当に新しい user namespace が作成されたのか確認してみましょう. 所属している user namespace の識別子は, シンボリックリンク /proc/self/ns/user
のターゲットを確認することで分かります. そこで, 関数 get_userns_id
を次のように定義します.
void get_userns_id(char *buf, size_t buf_len) {
char pathname[] = "/proc/self/ns/user";
ssize_t link_len = readlink(pathname, buf, buf_len - 1);
buf[link_len] = '\0';
}
get_userns_id
を使って, 親プロセスと子プロセスの両方で, 所属している user namespace の識別子を出力するように変更します.
int child_fn(void *args)
{
char userns_id[256];
get_userns_id(userns_id, sizeof(userns_id));
printf("child: %s\n", userns_id);
return 0;
}
int main()
{
char userns_id[256];
get_userns_id(userns_id, sizeof(userns_id));
printf("parent: %s\n", userns_id);
int flags = CLONE_NEWUSER | SIGCHLD;
// ...rest of the code remains same
}
実行すると, 次のような出力が得られます. 識別子が異なるので, 確かに新しい user namespace が作成されていることが分かります. 親プロセスが所属している user namespace は initial user namespace と呼ばれ, 初めから存在します.
parent: user:[4026531837]
child: user:[4026532623]
テンプレートコードを変更して, 子プロセスがコマンドライン引数で指定したコマンドを実行するようにします. こうしておくと便利です.
// ...rest of the include directives remain unchanged
#include <unistd.h>
int child_fn(void *args)
{
// execute the given command
char **argv = args;
execvp(argv[0], argv);
return 1; // should never reach here
}
int main(int argc, char *argv[])
{
if (argc < 2)
{
fprintf(stderr, "Error: No command provided\n");
return 1;
}
int flags = CLONE_NEWUSER | SIGCHLD;
pid_t pid = clone(
child_fn,
child_stack + CHILD_STACK_SIZE,
flags,
&argv[1]
);
// ...rest of the code remains same
}
現在の状態で子プロセスに id
コマンドを実行させると次のような結果になります.
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
子プロセスは nobody
というユーザーとして実行されているようです. しかし, 子プロセスに sleep
コマンドを実行させ, その間に別のシェルから ps
コマンドで子プロセスのユーザーを確認すると, 自分のユーザー名が表示されます. つまり, user namespace の内側と外側でプロセスのユーザーが異なる場合があります.
User namespace の user ID と, その親の user namespace の user ID のマッピングを定義することができます. 現状だと, 親の user namespace (initial user namespace) における自分の user ID に対応するマッピングがまだ定義されていないので, user namespace の内側では nobody
というユーザーが割り当てられました. nobody
ではなく root
が割り当てられるようにマッピングを定義しましょう.
子プロセスから /proc/self/uid_map
ファイルに所定の形式で書き込むことでマッピングを定義できます.
// ...rest of the include directives remain unchanged
#include <fcntl.h>
#include <string.h>
int child_fn(void *args)
{
char map_str[] = "0 1000 1\n";
int fd = open("/proc/self/uid_map", O_WRONLY);
write(fd, map_str, strlen(map_str))
close(fd);
// ...rest of the code remains same
}
ここでは 0
1000
1
の 3 つの数字をスペース区切りで書き込んでいます. 最初の数字は, 子プロセスが所属する user namespace の user ID を表します. この user ID から始まるある長さの範囲がマップされます. 2 番目の数字はマップ先の user ID の範囲の先頭を指定します. つまり, 2 番目の数字は親の user namespace における user ID を表します. 3 番目の数字は先ほどの説明に出てきた「ある長さ」を指定します. 従ってこの場合, 子プロセスが所属する user namespace の user ID 0
(root) と initial user namespace の user ID 1000
(自分) のマッピングが定義されます.
この状態で子プロセスに id
コマンドを実行させると, 今度は nobody
ではなく root
が割り当てられていることが確認できるはずです.
uid=0(root) gid=65534(nogroup) groups=65534(nogroup)
[!NOTE] Group ID のマッピング Group ID についてもほとんど同じ方法でマッピングを定義することができますが, 手順がひとつ増えます. 詳しくは user_namespaces(7) の man ページを参照して下さい.
ファイルのアクセス権チェックでは, initial user namespace における user ID が使われます. 従って, 新しい user namespace 内で root ユーザーになったとしても, 自分がアクセスできないファイルには相変わらずアクセスできません. このことを実際に確認してみましょう. 子プロセスでシェルを起動して色々試してみると良いです.
では, user namespace 内の root ユーザーは一般ユーザーと同等の権限しか持たないのでしょうか? 実は, user namespace 内の root ユーザーには全てのケーパビリティが付与されています. clone(2)
を使って新しい user namespace 内に作成した子プロセスは, 全てのケーパビリティを持った状態でスタートします. これにより, 子プロセスは user namespace 以外の namespace を分離すること, 即ち (rootless) コンテナを用意することができます (user namespace 以外の namespace の分離には CAP_SYS_ADMIN
ケーパビリティが必要). そして, その user namespace 内の root ユーザーになることで, execve(2)
後もケーパビリティが失われないのです.
実際に user namespace 以外の namespace を分離してみましょう. 上の説明だと, child_fn
関数内で unshare(2)
を呼び出す必要があるように思われますが, 実は clone
に渡す flags
に他の namespace のフラグを追加するだけで, 同じことを実現できます. 例えば mount namespace を分離する場合は次のようにします.
int flags = CLONE_NEWUSER | CLONE_NEWNS | SIGCHLD;
User namespace 内の root ユーザーは確かに全てのケーパビリティを持ちますが, ケーパビリティが有効な範囲が限定されています. 具体的には, その user namespace が管理するリソースに対してのみ有効です. User namespace が管理するリソースには, 所有する namespace のリソースも含まれます. User namespace 以外の namespace は, それを作成したプロセスが作成時に所属していた user namespace によって所有されます.
先ほど flags
に CLONE_NEWNS
を追加したので, user namespace に加えて mount namespace も作成されますが, mount namespace は user namespace に所有されることになります. 従って, mount namespace が管理するリソースに対する特権操作を行うことができます. 試しに tmpfs をマウントしてみましょう. 子プロセスでシェルを起動して, 次のコマンドを実行してみてください. 権限のエラーが発生せずに mount
コマンドを実行できるはずです.
# mkdir mnt
# mount -t tmpfs tmpfs ./mnt
他の user namespace が管理するリソースや, そもそもどの namespace にも管理されていないリソースはケーパビリティの有効範囲外です. 従って, 当然マシンのシャットダウンなどはできません.