Skip to content

Instantly share code, notes, and snippets.

@bdm-k
Created May 21, 2024 07:46
Show Gist options
  • Save bdm-k/ebde534c9a1c416ef4fa4361672db9e9 to your computer and use it in GitHub Desktop.
Save bdm-k/ebde534c9a1c416ef4fa4361672db9e9 to your computer and use it in GitHub Desktop.

Rootless コンテナの実装

概要

一般ユーザーの権限で実行できるコンテナのことを rootless コンテナと言います. Rootless コンテナを使うと, コンテナ内では root ユーザーとほぼ同じ権限を持つ一方で, コンテナの外では一般ユーザーの権限しか持たないようなプロセスを実行することができます. Rootless コンテナは, user namespace の機能を使用して実装します. User namespace とは, user ID, group ID, capabilities などのセキュリティに関連する属性を管理する namespace です.

User 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
}

User ID のマッピング

現在の状態で子プロセスに 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 ページを参照して下さい.

Rootless コンテナの権限

ファイルのアクセス権チェックでは, 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 によって所有されます.

先ほど flagsCLONE_NEWNS を追加したので, user namespace に加えて mount namespace も作成されますが, mount namespace は user namespace に所有されることになります. 従って, mount namespace が管理するリソースに対する特権操作を行うことができます. 試しに tmpfs をマウントしてみましょう. 子プロセスでシェルを起動して, 次のコマンドを実行してみてください. 権限のエラーが発生せずに mount コマンドを実行できるはずです.

# mkdir mnt
# mount -t tmpfs tmpfs ./mnt

他の user namespace が管理するリソースや, そもそもどの namespace にも管理されていないリソースはケーパビリティの有効範囲外です. 従って, 当然マシンのシャットダウンなどはできません.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment