今回は、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) の動作を試す
- Ubuntu 21.10 のルートファイルシステムに chroot(8) してみる
- chroot(2) の動作を試す
- chroot した環境から脱獄 (jail break) してみる
- まとめ
下準備
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 に書かれているし、巷にもいくつか解説が見つかる。
ざっくり説明すると、どうやら 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 をコマンドラインツールとシステムコールを使って試してみた。
-
念の為に補足しておくと、一般的な Linux コンテナ仮想化の実装ではデフォルトで chroot(2) ではなく pivot_root(2) が使われる↩