CUBE SUGAR CONTAINER

技術系のこと書きます。

chroot について

今回は、Unix の古典的な機能のひとつである chroot について扱う。 chroot を使うと、特定のプロセスにおけるルートディレクトリを、ルートディレクトリ以下にある別のディレクトリに変更できる。 今回扱うのはコマンドラインツールとしての chroot(8) と、システムコールとしての chroot(2) になる。

使った環境は次のとおり。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.4 LTS
Release:    20.04
Codename:   focal
$ uname -srm
Linux 5.4.0-104-generic aarch64
$ chroot --version
chroot (GNU coreutils) 8.30
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Roland McGrath.
$ gcc --version
gcc (Ubuntu 9.4.0-1ubuntu1~20.04) 9.4.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ ldd --version
ldd (Ubuntu GLIBC 2.31-0ubuntu9.7) 2.31
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.

もくじ

下準備

chroot(8) は coreutils パッケージに含まれているのでインストールしておく。 また、chroot(2) を呼び出すコードをビルドするために build-essential をインストールする。

$ sudo apt-get -y install coreutils build-essential

chroot(8) の動作を試す

まずはコマンドラインツールとしての chroot(8) から動作を確認していく。

はじめに、chroot(8) したプロセスでルートディレクトリになるディレクトリを用意する。 ディレクトリは mktemp(1) を使ってテンポラリディレクトリとして作る。

$ ROOTFS=$(mktemp -d)
$ echo ${ROOTFS}
/tmp/tmp.GuMwStXLLO

chroot(8) した上で起動するプログラムとして bash(1) をコピーしておく。 このとき --parents オプションを使ってディレクトリ構造ごとコピーしてやる。

$ cp -avL --parents $(which bash) ${ROOTFS}
/usr -> /tmp/tmp.GuMwStXLLO/usr
/usr/bin -> /tmp/tmp.GuMwStXLLO/usr/bin
'/usr/bin/bash' -> '/tmp/tmp.GuMwStXLLO/usr/bin/bash'

さらに、bash(1) の動作に必要な共有ライブラリをコピーする。 動作に必要な共有ライブラリは ldd(1) の出力から得られる。

$ ldd $(which bash) | grep -o "/lib.*\.[0-9]\+" | xargs -I {} cp -avL --parents {} ${ROOTFS}
/lib -> /tmp/tmp.GuMwStXLLO/lib
/lib/aarch64-linux-gnu -> /tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu
'/lib/aarch64-linux-gnu/libtinfo.so.6' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libtinfo.so.6'
'/lib/aarch64-linux-gnu/libdl.so.2' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libdl.so.2'
'/lib/aarch64-linux-gnu/libc.so.6' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libc.so.6'
'/lib/ld-linux-aarch64.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/ld-linux-aarch64.so.1'

上記の作業を、必要なプログラムそれぞれについてやっていく。 手作業でひとつひとつやると大変なのでループを回して処理する。 ここでは例として ls, mkdir, mount をコピーした。

$ CMDS=ls,mkdir,mount
$ IFS=","
$ for CMD in ${CMDS}
> do
>   cp -avL --parents $(which ${CMD}) ${ROOTFS}
>   ldd $(which ${CMD}) | grep -o "/lib.*\.[0-9]\+" | xargs -I {} cp -avL --parents {} ${ROOTFS}
> done
'/usr/bin/ls' -> '/tmp/tmp.GuMwStXLLO/usr/bin/ls'
'/lib/aarch64-linux-gnu/libselinux.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libselinux.so.1'
'/lib/aarch64-linux-gnu/libc.so.6' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libc.so.6'
'/lib/ld-linux-aarch64.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/ld-linux-aarch64.so.1'
'/lib/aarch64-linux-gnu/libpcre2-8.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpcre2-8.so.0'
'/lib/aarch64-linux-gnu/libdl.so.2' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libdl.so.2'
'/lib/aarch64-linux-gnu/libpthread.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpthread.so.0'
'/usr/bin/mkdir' -> '/tmp/tmp.GuMwStXLLO/usr/bin/mkdir'
'/lib/aarch64-linux-gnu/libselinux.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libselinux.so.1'
'/lib/aarch64-linux-gnu/libc.so.6' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libc.so.6'
'/lib/ld-linux-aarch64.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/ld-linux-aarch64.so.1'
'/lib/aarch64-linux-gnu/libpcre2-8.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpcre2-8.so.0'
'/lib/aarch64-linux-gnu/libdl.so.2' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libdl.so.2'
'/lib/aarch64-linux-gnu/libpthread.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpthread.so.0'
'/usr/bin/mount' -> '/tmp/tmp.GuMwStXLLO/usr/bin/mount'
'/lib/aarch64-linux-gnu/libmount.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libmount.so.1'
'/lib/aarch64-linux-gnu/libc.so.6' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libc.so.6'
'/lib/ld-linux-aarch64.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/ld-linux-aarch64.so.1'
'/lib/aarch64-linux-gnu/libblkid.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libblkid.so.1'
'/lib/aarch64-linux-gnu/libselinux.so.1' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libselinux.so.1'
'/lib/aarch64-linux-gnu/libpcre2-8.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpcre2-8.so.0'
'/lib/aarch64-linux-gnu/libdl.so.2' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libdl.so.2'
'/lib/aarch64-linux-gnu/libpthread.so.0' -> '/tmp/tmp.GuMwStXLLO/lib/aarch64-linux-gnu/libpthread.so.0'

準備が終わったところで、満を持して chroot(8) する。 第 1 引数は chroot(8) したプロセスでルートディレクトリになるディレクトリ。 第 2 引数は chroot(8) した上で起動するコマンドの場所。

$ sudo chroot ${ROOTFS} $(which bash)

実行すると、先ほどとは異なるシェルとして bash(1) が立ち上がる。 試しにルートディレクトリを ls(1) してみよう。 あきらかに、普段とは表示されるディレクトリの数が異なる。 lib と usr しかない。 とはいえ、これは先ほどコピーしたファイルのあったディレクトリなので心当たりは十分にあるはず。

# ls /
lib  usr

ここで、試しに proc ファイルシステムをマウントしてみよう。 ディレクトリを用意してマウントする。

# mkdir -p /proc
# mount -t proc proc /proc

すると、次のようにちゃんとマウントできる。 なお、chroot(8) ではルートディレクトリを切り替えるだけなので、PID (プロセス識別子) の名前空間はシステムと共有している。

# ls /proc
1     1448  178  240  474  501    623  70  80   98     diskstats    kallsyms     mdstat    schedstat  thread-self
10    1449  179  287  475  522    625  71  81   acpi    driver       kcore    meminfo   scsi       timer_list
104   1458  18    288  476  574   627  72  83   buddyinfo  execdomains  key-users    misc       self       tty
11    15    19    3    477  576   629  73  84   bus     fb       keys     modules   slabinfo   uptime
12    16    2     361  486  6 632  74  841  cgroups     filesystems  kmsg     mounts    softirqs   version
1359  1685  20    374  488  612   646  75  842  cmdline     fs       kpagecgroup  net       stat       version_signature
1376  1686  21    380  489  615   648  76  85   consoles    interrupts   kpagecount   pagetypeinfo  swaps      vmallocinfo
1377  1691  22    392  496  616   666  77  86   cpuinfo     iomem        kpageflags   partitions    sys        vmstat
14    17    23    4    499  621   673  78  9    crypto  ioports      loadavg      pressure  sysrq-trigger  zoneinfo
143   177   24    473  500  622   686  8     95   devices   irq          locks    sched_debug   sysvipc

確認が終わったらシェルを終了しよう。 これで chroot(8) を呼び出した元のプロセスに戻れる。

# exit
exit

Ubuntu 21.10 のルートファイルシステムに chroot(8) してみる

次は、試しに他の GNU/Linux ディストリビューションのルートファイルシステムを展開して chroot(8) してみよう。 今、システムとして使っているのが Ubuntu 20.04 LTS なので、Ubuntu 21.10 を使うことにした。

まずは Ubuntu 21.10 の、ルートファイルシステムをアーカイブしたファイルをダウンロードして展開する。 CPU の命令セットが違うとダウンロードするファイルが異なる点に注意する。

$ ISA=$(uname -m | sed -e "s/x86_64/amd64/" -e "s/aarch64/arm64/")
$ mkdir -p /tmp/ubuntu-impish-${ISA}
$ wget -O - https://cdimage.ubuntu.com/ubuntu-base/releases/21.10/release/ubuntu-base-21.10-base-${ISA}.tar.gz | tar zxvf - -C /tmp/ubuntu-impish-${ISA}

次のように /tmp 以下にファイルが展開された。

$ ls /tmp/ubuntu-impish-${ISA}/
bin  boot  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

ここで、展開されたディレクトリに対して chroot(8) してみよう。

$ sudo chroot /tmp/ubuntu-impish-${ISA} /usr/bin/bash

これで Ubuntu 21.10 のルートファイルシステムが、プロセスのルートディレクトリになった。 例えば /etc 以下にある lsb-release ファイルを表示すると Ubuntu 21.10 のものになっている。 bash のバージョンも Ubuntu 21.04 LTS の 5.0 系ではなく 5.1 系になっている。

# cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=21.10
DISTRIB_CODENAME=impish
DISTRIB_DESCRIPTION="Ubuntu 21.10"
# bash --version
GNU bash, version 5.1.8(1)-release (aarch64-unknown-linux-gnu)
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

システムは Ubuntu 20.04 LTS なのに、なんだか Ubuntu 21.10 を使っているような気分になる。 一方で、uname(1) から得られるカーネルのバージョンは Ubuntu 20.04 LTS のまま。

# uname -r
5.4.0-104-generic

これは、単に chroot(8) でルートディレクトリを入れ替えているだけなので当たり前。 Linux コンテナ技術は基本的にカーネルを共有するので Docker などを使っていても、この点は変わらない 1

chroot(2) の動作を試す

続いてはシステムコールとしての chroot(2) の動作を試してみる。

以下のサンプルコードでは、第 1 引数で指定されたパスに chroot(2) した上で bash(1) を起動している。

#define _XOPEN_SOURCE

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>


int main(int argc, char *argv[]) {
    // 引数の長さをチェックする
    if (argc < 2) {
        fprintf(stderr, "Please specify the path to change root\n");
        exit(EXIT_FAILURE);
    }

    // chroot(2) したいディレクトリにカレントワーキングディレクトリを変更する
    if (chdir(argv[1]) != 0) {
        fprintf(stderr, "Failed to change directory: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // カレントワーキングディレクトリに chroot(2) する
    if (chroot(".") != 0) {
        fprintf(stderr, "Failed to change root: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // シェルを起動し直す
     char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

上記をコンパイルする。

$ gcc --std=c11 --static -Wall chroot.c 
$ file a.out 
a.out: ELF 64-bit LSB executable, ARM aarch64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=362e58fceadfa88e4ef8f7becdb06350922b9930, for GNU/Linux 3.7.0, not stripped

できたバイナリに第 1 引数として Ubuntu 21.10 のディレクトリを指定して実行する。

$ sudo ./a.out /tmp/ubuntu-impish-${ISA}/

すると、次のようにちゃんと Ubuntu 21.10 のルートファイルシステムがルートディレクトリになっている。 つまり chroot(8) を使ったときと同じ結果になった。

# ls /
bin  boot  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
# cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=21.10
DISTRIB_CODENAME=impish
DISTRIB_DESCRIPTION="Ubuntu 21.10"

ひとしきり確認したら環境から抜ける。

# exit

chroot した環境から脱獄 (jail break) してみる

実は chroot で隔離したファイルシステムは、プロセスに CAP_SYS_CHROOT のケーパビリティがあると脱獄できることが知られている。 これはあくまで chroot(2) の仕様であって、不具合や脆弱性ではないらしい。 では、実際に脱獄できるのか確かめてみよう。

以下にサンプルコードを示す。 このコードをビルドしたバイナリを chroot した環境で実行することで脱獄する。 コードでは "foo" という名前でディレクトリを作って、そこに chroot(2) している。 その上で chdir(2) を何度も呼び出して、その後でまたカレントワーキングディレクトリに対して chroot(2) している。 そして、最後に bash(1) を呼び出している。 やっていることは実にシンプル。

#define _XOPEN_SOURCE

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>


int main(int argc, char *argv[]) {
    // chroot した環境で実行されることを想定している

    // 適当にサブディレクトリを作る
    if (mkdir("foo", 755) != 0) {
        // すでに同名のパスがあるときはエラーを無視する
        if (errno != EEXIST) {
            fprintf(stderr, "Failed to create a new directory: %s\n", strerror(errno));
            exit(EXIT_FAILURE);
    }
    }

    // 作成したサブディレクトリに chroot(2) する
    // chroot(2) は pwd を変更しない
    // rootfs が pwd よりも下のディレクトリになる
    if (chroot("foo") != 0) {
        fprintf(stderr, "Failed to change root: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // chdir(2) は rootfs に到達するまで pwd から早退パスで移動できる
    // ただし、現状 rootfs は pwd よりも下にあるので決して到達しない
    // 元々のルートディレクトリまでさかのぼってしまう
    for (int i = 0; i < 1024; i++) {
        if (chdir("..") != 0) {
            fprintf(stderr, "Failed to change directory: %s\n", strerror(errno));
            exit(EXIT_FAILURE);
        }
    }

    // ルートディレクトリまでいってから chroot(2) すると脱獄できる
    if (chroot(".") != 0) {
        fprintf(stderr, "Failed to change root: %s\n", strerror(errno));
        exit(EXIT_FAILURE);
    }

    // 脱獄した上でシェルを起動し直す
    char* const args[] = {"bash", NULL};
    if (execvp(args[0], args) != 0) {
        fprintf(stderr, "Failed to exec \"%s\": %s\n", args[0], strerror(errno));
        exit(EXIT_FAILURE);
    }

    return EXIT_SUCCESS;
}

上記の概念的な説明は man 2 chroot に書かれているし、巷にもいくつか解説が見つかる。

man7.org

ざっくり説明すると、どうやら chroot(2) がプロセスのカレントワーキングディレクトリを変更しないところがポイントらしい。 サブディレクトリに chroot(2) すると、プロセスのルートディレクトリはサブディレクトリになるが、カレントワーキングディレクトリは元のまま変更されない。 つまり、カレントワーキングディレクトリよりもルートディレクトリの方が下位のディレクトリにあるという、なんだか変な状況になる。 そして、カレントワーキングディレクトリから相対パスで chdir(2) する場合、ルートディレクトリに至るまで上位のディレクトリに移動できるらしい。 しかし、ルートディレクトリはカレントワーキングディレクトリよりも下位にあるため、決してそこに至ることはなく本来の隔離される前のルートディレクトリまで到達してしまう。 そこで改めて chroot(2) すると、晴れてプロセスのルートディレクトリが変更されて脱獄成功、ということらしい。

理屈は分かったので、実際に試してみよう。 上記をコンパイルする。

$ gcc --std=c11 --static -Wall jailbreak.c

できたバイナリを、先ほど展開した Ubuntu 21.10 のルートファイルシステムに放り込む。

$ cp a.out /tmp/ubuntu-impish-${ISA}/

上記のディレクトリを指定して chroot(8) する。

$ sudo chroot /tmp/ubuntu-impish-${ISA} /usr/bin/bash

Ubuntu 21.10 のファイルシステムに隔離されたことを確認する。

# cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=21.10
DISTRIB_CODENAME=impish
DISTRIB_DESCRIPTION="Ubuntu 21.10"

ここでおもむろに先ほどコピーしたバイナリを実行してみる。

# /a.out

一見すると変化はないが /etc/lsb-release を確認すると隔離前のファイルシステムに参照できている。 つまり、脱獄できた。

# cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.4 LTS"

このような脱獄を防ぐには、根本的には chroot(2) の代わりに pivot_root(2) を使う必要があるようだ。

まとめ

今回は chroot をコマンドラインツールとシステムコールを使って試してみた。


  1. 念の為に補足しておくと、一般的な Linux コンテナ仮想化の実装ではデフォルトで chroot(2) ではなく pivot_root(2) が使われる