コードリーディング(C言語):chroot / ischroot

前書き

本記事は、以下のコマンドのコードリーディング結果を記載しています。

  • プロセスおよび子プロセスの(見かけ上の)ルートディレクトリを変更するchroot
  • プロセスがchroot環境(jail環境)で動作しているかを検出するischroot

上記のコマンドをリーディング対象とした理由は、以下の2点です。

  • 「見かけ上のルートディレクトリ変更」の実現方法を知りたかった。
  • コード規模が小さい(一日で読めそう)。

 

一つ目の理由に関しては、仕事でchroot環境を使う機会があり、そこで興味を持ちました。ちなみに、chrootで制限された環境は、chroot監獄と呼ばれる事があります。chrootは、「システム環境を破壊する可能性のあるプログラムをテストする環境」や「ライブラリの依存を確認するためにパッケージを制限した環境」を提供できます。しかし、2019年現在ではこのような用途は、より簡便なDockerによるコンテナ環境がメインになりつつあります。

二つ目のコード規模に関しては、下表にstep数を記載します。計測対象は、coreutilsに含まれるchroot.c、debianutils-4.4に含まれるischroot.cに対して、clocコマンドを用いて計測しています。調査した期間が少し前なので、パッケージバージョンが古いです。しかし、基本的な仕組みを知るには問題がないので、再調査はしませんでした。

ファイル名 step数 提供パッケージ
chroot.c 305step coreutils-8.23
ischroot.c 159step debianutils-4.4

                                  

検証環境

                              

chroot実装調査:coreutilesパッケージの取得

Debian環境下で、coreutilesパッケージ(ソースコード、パッチ)を取得する方法は以下の通りです。Debianのバージョンが異なる場合でも、同じ方法で取得できます。

                                       

chroot実装調査:coreutils内ソースファイルの共通初期化処理

まず、coreutils内のソースファイルに共通する初期化処理に関して、chrootのソースファイルを例として説明します。説明対象は、coreutiles-8.23/src/chroot.cです。

initialize_main()は、DEC(現HP)が開発したVirtual Memory System(OS、以下VMS)上で、リダイレクト・ワイルドカードの仕組みを実現するための関数(マクロ)です。coreutiles-8.23/src/system.hにおける定義の通り、通常(Debian)では何も処理を実施しないマクロになっています。

この点に関しては、stackoverflowに説明がありました。以下に和訳を記します。原文は、リンク先で確認できます。 

次のステップは、リダイレクトとワイルドカードを理解する事です。
Linuxやunix系統のその他のメンバーは、

cat foo* > /tmp/foolist

のようなコマンドはfoo*にマッチする内容を引数argvとして、catコマンドの関数main()をcallします。
関数main()に実行が移される前に、出力ファイル/tmp/foolistは既に標準出力として開かれています。
VMSは、このような動作をしません。
catコマンドは拡張されていない文字列”foo*”を探し、リダイレクト演算子”>”をargvに格納します。
そのため、ユーティリティ自体(cat)がリダイレクト(出力ファイルを開く事)および
ワイルドカード(“foo*”を”foo1″、”foo2″、”foo3″に置換する事)を実施しなければなりません。

 なお、VMS上でリダイレクトとワイルドカードを実行する処理は、system.hに定義されていません。GNU版core-utiles-8.26のsystem.hでは、OS/2向けの処理(下記)が実装されていました。GNU版はVMS上で動かす可能性があるが、Debian版は動かす可能性がないため、initialize_main()の定義に差があるのではないかと推測しました(Changelogにはこの点に関する記載なし)。

続いて、set_program_name()は、変数program_name、program_invocation_nameに、コマンドライン引数”argv[0]=プログラム名(chroot)”を代入します。

一般的に、argv[0]にはプログラム名が格納されていますが、以下の条件ではその限りではありません。

  • 関数main()をプログラム内からコールした場合(例:再帰的なmain()のコール)
  • argc=0の場合(仕様として、argv[0]=NULLとなる)

上記の場合に備えたNULLチェックは、以下のように実装されています。argv[0]がNULLの場合は標準エラーにメッセージを出力した後に、プログラムを異常終了させます。

NULLチェックを終えた後、プログラム名を代入する前に、set_program_name()にて文字列を整形します。例として、プログラム名が”/.libs/lt-*”の場合、”/.libs/lt-“を取り除いた文字列を代入します。

このようなプログラム名は、libtoolを使用してビルドを行った場合に命名されます。libtoolを用いてビルドした共有ライブラリは、異なるOSで共有オブジェクトを扱う際の制約を回避するように、ラッパースクリプトが生成されます。そして、実際にリンクされた(ラップされた)実行可能ファイルは.libsディレクトリ内で、プレフィックス”lt-“が付与された名称で存在します。 

以下が、文字列整形部分の処理になります。

初期化処理のsetlocale()からtextdomain()までは、プログラムの国際化・地域化に関する設定を行います。具体的には、localeを適切に設定する事により、文字/日付/時刻/単位/通貨などを特定の地域に特化した状態でプログラムを実行できるようにします。

プログラムのローカライズの流れは、以下の順番で行われます(詳細は後述)。

  1. プログラムのメッセージ文を翻訳用関数gettext()に対応した形式で記述
  2. 前手順1.のメッセージ文に対する翻訳文をカタログファイル(.po、書式あり)に記載
  3. 前手順2.のカタログファイルをmsgfmtコマンドでコンパイルし、バイナリ(.mo)を生成。バイナリは”ドメイン名.mo”となり、/usr/share/locale//LC_MESSAGES以下に格納されます
  4. プログラム中にlocaleに関する初期化を記述

上記の手順4. に当たる内容がsetlocale()からtextdomain()までとなります。
まず、setlocale()は、プログラムに環境変数(LC_
)を参照させ、locale情報を設定します。
挙動としては、環境変数LC_ALLもしくはLANGが設定されていれば全てのカテゴリでその設定を使用し、各カテゴリ(例:LC_TIME)に設定値が入っていれば、そのカテゴリはその設定値を優先的に使用します。


なお、システムの現在のlocale情報を参照するには、以下のようにlocaleコマンドを実行します(出力の左辺値がカテゴリ)。

                                            

                                      

次に、bindtextdomain()は、ドメイン名”PACKAGE”のカタログディレクトリを”LOCALEDIR”に設定します。具体的には、変数PACKAGEはMakefile中に”coreutils”と定義され、変数LOCALEDIRはlib/configmake.h中に”/usr/share/locale”と定義されています。

例えば、日本語環境のcoreutilsがメッセージカタログを参照する場合は、”/usr/share/locale/ja/LC_MESSAGES/coreutils.mo”を参照する事となります。

 最後に、textdomain()はドメイン名を”PACKAGE=coreutils”に設定します。
ここで指定したドメイン情報用いて、gettext()がメッセージを適切なlocaleに翻訳(置換)します。gettext()は、エイリアスとして”_”が与えられているため、メッセージの前の”_”はgettext()と同等です。

例えば、chroot.cの402行目のerror (EXIT_CANCELED, errno, _("failed to set group-ID"));は、その翻訳が以下のpoファイルに定義されています。

残りのinitialize_exit_failure()からatexit()までは、プログラム終了に関する処理です。
initialize_exit_failure()では終了コードを管理するグローバル変数exit_failureの初期化、atexit()ではプロセス終了時に自動実行するハンドラ(今回はclose_stdout())を登録します。

一般論として、atexit()には、以下の2つの欠点があります。

  • exit()の引数である終了コードを参照できない事
  • 登録したハンドラ引数を渡せない事

これらの欠点を解消するために、グローバル変数exit_failureで終了コードを管理し、
close_stdout()内で終了コード(exit_failure)を引数として_exit()をコールします。
なお、可搬性はありませんが、ハンドラ登録と引数が渡せるon_exit()も存在します。

ハンドラとして登録されたclose_stdout()の定義は、以下の通りです。
close_stdout()を実行するプログラムは、終了する前にstdoutおよびstderr以外の
ストリームをフラッシュする必要があります。この理由は、_exit()がRuntimeライブラリのクリーンアップ処理を実行しないため、ライブラリが使用したストリームバッファが必ずフラッシュされるという保証がないからです。

      

chrootのオプション処理

初期化処理に続いて、chrootが受け取るオプションの取り扱い方法を説明します。実装を説明する前に、chrootの書式およびオプションを以下に示します。

オプション 説明
–userspec=USER:GROUP 使用するユーザーおよびグループ (ID または名前) を指定します
–groups=G_LIST g1,g2,..,gN 形式で追加のグループを指定します
–help chrootの使い方を表示します
–version バージョン情報を表示します

上表のオプションを受け取る処理は、以下の通りです(初期化処理のatexit()の続き)。

オプション解析では、全オプションを解析し終わるまでwhileループを回し、getopt_long()で指定したオプションの引数をchar型のuserspec/groupsに格納します。最後に、解析できなかったオプションがあれば、usageを表示してプログラムを終了します。

なお、getopt_long()は、ハイフン2つ+複数文字のオプション(例:–help)を解析します。getopt_long()は、返り値としてoption構造体で指定した値(後述)を返し、
変数optindは解析のたびに1インクリメント、変数optargはオプション引数を保持しています。

getopt_long()の第4引数long_opts(option構造体)の定義は、以下の通りです。

                                            

ルートディレクトリの変更処理

ルートディレクトリの変更処理を説明します。この変更処理前にはuid/gidを取得する処理、変更後には変更後のルートに移動する処理を行います。

以下に該当処理を示します。英語コメントは和訳しています。

まず、ignore_value(parse_user_spec())において、uid、gidを取得します。ignore_valueは、GCC(Ver3.4以降)において値をvoid型にキャストした際のWarningを抑制するマクロです。parse_user_specは、オプション引数を格納したuserspec(コロン区切りで、user名:group名を保持)より、USERNAME、uid、GROUPNAME、gidを抽出します。グループなしで区切りを指定した場合(例:”user:”)、指定ユーザーのログイングループが使用されます。

続くif (uid_set (uid) && (! groups || gid_unset (gid)))以下のブロックは、—groupsオプションで補助グループが指定されていない場合、もしくはparse_user_spec()でグループを解析できなかった場合に実行されます。その内容は、uidを基にパスワードファイルのエントリーを取得し(getpwuid()部分)、そのエントリーからグループ名(pwd->pw_gid)、ユーザ名(pwd->pw_name)を取得する事です。

前述のブロックとは異なり、続くparse_additional_groups()は、—groupsオプションで補助グループが指定されている場合に実行されます。parse_additional_groups()の第一引数(groups)には、カンマ区切りで補助グループが指定されています。この指定された補助グループ情報より、parse_additional_groups()は、gid_t型の配列(out_gids)、グループの個数(n_gids、配列の要素数)を取得します。

最後に、chrootコマンドのメインとなる処理がchroot()、chdir(“/”)部分です。引数となるargv[optind]のチェック(指定されたPATHが存在するかのチェック)を行っていないため、不正なPATH(argv[optind])をchroot()に渡すと、エラーとなります。chroot()以降、プロセスが処理する絶対PATHは、chroot()の引数を開始点として処理されます。chdir()は、chroot()がワーキングディレクトリを変更しないため、代わりにワーキングディレクトリを変更します。

ちなみに、chdir(“/”)を行わなければ、容易にプロセスがjail環境から抜け出せます。ここまでソースコードを読んで分かった事ですが、

個人的な結論

chrootコマンド ≒ chrootシステムコール

                                       

ルートディレクトリ変更後のコマンド処理

前提として、chrootコマンドは、その引数にjail環境で実行するコマンドを指定できます。コマンドの指定がない場合は、シェルによる対話型の処理が始まります。

ルートディレクトリの変更後のコマンド処理を以下に示します。

まず、最初のif(argc == optind + 1)〜elseのブロックを説明します。chrootコマンドにjail環境で実行したいコマンドが指定されていない場合(if文の方)、環境変数SHELLから使用するシェルを指定します。

環境変数SHELLが設定されていなければ、強制的にargv[0]は”/bin/sh”となります。argv[1]は、シェルを対話型で起動させるオプション(“-i”)を指定します。argv[2]にNULLを代入する理由は、execvp()でコマンド(argv[0])を実行する際に、引数のリストにNULL終端が必要なためです。

続くif(userspec)、if(uid_set (uid) && (! groups || gid_unset (gid)))、
if(groups && *groups)のブロックの内容は、再度uid/gid/補助グループを取得する処理になります。同じ処理を2回繰り返す理由は、元々のルート環境とjail環境では、uid/gidの設定が異なる可能性があるからです。具体的には、2つの環境において、/etc/passwd(uidの名前解決用ファイル)、/etc/group(gidの名前解決用ファイル)が同じ内容ではない可能性があります。そのため、2回ほどuid/gid/補助グループを取得する必要があります。

最後までの流れは、uid/gid/補助グループの設定、およびexecvp()によるコマンド実行です。必要なコマンド・ライブラリがなければ、エラー終了です。

                             

         

ischroot実装調査:debianutilsパッケージの取得

基本的に、debianutilsパッケージの取得は、coreutilsの取得と同じです。

ischrootコマンドの挙動(manより抜粋)

ischrootコマンドは、マイナーなため、使用方法を説明します。本コマンドは、現プロセスがjail環境かどうかをischrootコマンドの返り値で示します。

以下に、返り値の一覧を示します。

返り値 説明
0 現在のプロセスは、jail環境で動作している。
1 現在のプロセスは、通常の環境で動作している。
2 ischrootコマンドを一般ユーザが実行している(ischrootは管理者権限が必須)。

実行例を以下に示します。

ischrootのオプションは、以下の通りです。

オプション 説明
-f, –default-false ischrootコマンドを一般ユーザが実行した場合の返り値が1になる。
-t, –default-true ischrootコマンドを一般ユーザが実行した場合の返り値が0になる。
–help ヘルプを表示する
–version バージョン情報を表示する

                                              

ischrootの実装

最初のオプション解析部分は、chrootコマンドと同じ要領で解析できるので、説明を割愛します。(オプションであるdefault_false、default_trueを同時に指定した場合は、エラー終了のようですね)

続くisfakechroot()の定義は、以下の通りです。

isfakechroot()では、環境がFAKECHROOT環境か、必要なライブラリがpreloadされているか確認します。ここでのFAKECHROOT環境では、libc(glibc)関数を上書きするライブラリをpreloadされているため、root権限無しでパッケージを構築する作業などが可能になります。

具体的には、ここでのpreloadされているライブラリとはlibfakechroot.soであり、
preloadされているかは環境変数LD_PRELOADにlibfakechroot.soへのPATHがあるかで判断できます。なお、libfakechroot.soには、libc(glibc)関数が再定義されており、
LD_PRELOADを指定してアプリを実行した場合、再定義された関数を用いてアプリが動作します。

続くischroot()は、現在の環境がFAKECHROOT環境でない場合のみ、実行されます。
ischrootは、configure時に指定した実行環境により、定義が異なります。ここでの定義は、Linux環境/FreeBSD環境/GNU Hurd環境/その他、の4種類存在します。

今回は、Debian環境であるため、Linux環境の定義を以下に示します。

ischroot()では、”/”のデバイスIDおよび”/proc/1/root”のデバイスID、”/”のinode番号および”/proc/1/root”のinode番号を比較します。”/proc/1/root”が存在し、かつ管理者権限で実行しているにも関わらず、”/proc/1/root”の状態が読み取れない場合は、jail環境と判定されます。

なお、返り値として2を返すのは、以下のケースが想定されています。

  • ischrootコマンドが管理者権限で実行されていない場合
  • “/”の情報が読み取れない場合(/procがマウントされていない場合)
  • /proc/1/rootが存在しない場合

以上が、ischrootコマンドの処理となります。

                       

参考書籍

LINUXプログラミングインタフェース(O’Reilly)
Inside Linux Software オープンソースソフトウェアのからくりとしくみ(翔泳社)

    

最後に

coreutilsは、様々なOS環境で動作させる事を意識した作りであったため、「この訳分からん処理は、どんな意図で実装されているんだ……」と感じる点が多々ありました。ただし、文献数やマクロの変態さを考慮すれば、Linux Kernelよりcoreutilsは読みやすい部類かなと。

     

おすすめ