Chapter 3:
プログラムを実行する方法 Edit on GitHub

これまで、CPUが実行可能ファイルから読み込まれたマシンコードを実行する方法、リングベースのセキュリティについて、およびシスコールの動作について説明しました。このセクションでは、最初にプログラムがどのように読み込まれて実行されるかを理解するために、Linuxカーネルの詳細について掘り下げて説明します。

具体的には、Linuxをx86-64で見ていきます。なぜなら?

学んだことのほとんどは、特定の方法で異なる場合でも、他のオペレーティングシステムとアーキテクチャにも一般的に適用されます。

execシスコールの基本動作

execシスコールを示すフローチャート。左には「ユーザースペース」とラベル付けされたフローチャートアイテムのグループがあり、右には「カーネルスペース」とラベル付けされたグループがあります。ユーザースペースグループで始まり、ユーザーはターミナルで./file.binを実行し、それからexecve("./file.bin", ...)シスコールを実行します。これはSYSCALL命令の実行に流れ、それからカーネルスペースグループの最初のアイテムを指します。「バイナリの読み込みとセットアップ」が「binfmtを試す」を指します。 binfmtがサポートされている場合、新しいプロセスを開始します(現在のプロセスを置き換えます)。サポートされていない場合、binfmtを再試行します。

非常に重要なシステムコールである「execve」から始めましょう。これはプログラムを読み込み、成功した場合には現在のプロセスをそのプログラムで置き換えます。他にもいくつかのシスコール(「execlp」、「execvpe」など)が存在しますが、それらはすべてさまざまな方法で「execve」の上に重ねています。

傍注: execveat

実際には、「execve」は「execveat」の上に構築されており、プログラムをいくつかの構成オプションで実行するより一般的なシスコールです。簡単に説明すると、主に「execve」について話します。唯一の違いは、「execveat」にいくつかのデフォルトが提供されることです。

「ve」の「ve」は、1つのパラメータが引数(argv)のベクトル(リスト)であることを意味し、「e」はもう1つのパラメータが環境変数(envp)のベクトルであることを意味します。さまざまな他のexecシスコールには、異なる呼び出し署名を指定するための異なる接尾辞があります。「execveat」の「at」は、実行する場所を指定するものです。

execveの呼び出しシグネチャは次の通りです:

int execve(const char *filename, char *const argv[], char *const envp[]);

面白い事実!プログラムの最初の引数がプログラムの名前であるという慣習、それは純粋に慣習 であり、実際にはexecveシスコール自体によって設定されていないのです!最初の引数は、argv引数の最初の項目としてexecveに渡されたものです。たとえそれがプログラム名とは何の関係もない場合でもです。

興味深いことに、execveにはargv[0]がプログラム名であると想定しているコードがいくつかあります。解釈型スクリプト言語について話す際に詳しく説明します。

ステップ 0: 定義

システムコールがどのように動作するかはすでに知っていますが、実際のコード例を見たことはありません! Linuxカーネルのソースコードを見て、execveが内部でどのように定義されているかを見てみましょう:

fs/exec.c
SYSCALL_DEFINE3(execve,
		const char __user *, filename,
		const char __user *const __user *, argv,
		const char __user *const __user *, envp)
{
	return do_execve(getname(filename), argv, envp);
}

SYSCALL_DEFINE3は、3つの引数を持つシステムコールのコードを定義するためのマクロです。

マクロ名にアリティ(arity)がハードコードされている理由について興味を持ち、検索してみたところ、これはあるセキュリティの脆弱性を修正するための回避策であることが分かりました。

ファイル名の引数は、getname()関数に渡され、この関数はユーザースペースからカーネルスペースに文字列をコピーし、いくつかの使用状況トラッキング処理を行います。それはinclude/linux/fs.hで定義されているfilename構造体を返します。これはユーザースペースの元の文字列へのポインタと、カーネルスペースにコピーされた値への新しいポインタを保存します。

include/linux/fs.h
struct filename {
	const char		*name;	/* pointer to actual string */
	const __user char	*uptr;	/* original userland pointer */
	int			refcnt;
	struct audit_names	*aname;
	const char		iname[];
};

execve システムコールは、do_execve() 関数を呼び出します。これに続いて、一部のデフォルトを持つ do_execveat_common() が呼び出されます。前述の execveat システムコールもまた do_execveat_common() を呼び出しますが、より多くのユーザー指定オプションを通過させます。

以下のスニペットでは、do_execvedo_execveat の定義を両方含めています:

fs/exec.c
static int do_execve(struct filename *filename,
	const char __user *const __user *__argv,
	const char __user *const __user *__envp)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };
	return do_execveat_common(AT_FDCWD, filename, argv, envp, 0);
}

static int do_execveat(int fd, struct filename *filename,
		const char __user *const __user *__argv,
		const char __user *const __user *__envp,
		int flags)
{
	struct user_arg_ptr argv = { .ptr.native = __argv };
	struct user_arg_ptr envp = { .ptr.native = __envp };

	return do_execveat_common(fd, filename, argv, envp, flags);
}

[spacing sic]

execveatにおいて、ファイルディスクリプタ(ある種のリソースを指すIDの一種)がシスコールに渡され、それからdo_execveat_commonに渡されます。これにより、プログラムを実行するディレクトリが相対的に指定されます。

execveにおいて、ファイルディスクリプタの引数として特別な値、AT_FDCWDが使用されます。これはLinuxカーネル内の共有定数で、関数にパス名を現在の作業ディレクトリを基準として解釈するよう指示します。通常、ファイルディスクリプタを受け入れる関数は、以下のように手動でチェックを行います:if (fd == AT_FDCWD)

ステップ 1: セットアップ

今、私たちはプログラムの実行を扱うコア関数である「do_execveat_common」に到達しました。この関数が何を行うかをコードを見つめる一時的な一歩を踏み出し、この関数が何を行うかの全体像を把握しましょう。

「do_execveat_common」の最初の主要な仕事は、「linux_binprm」と呼ばれる構造体をセットアップすることです。この構造体の全体の定義のコピーは含めませんが、以下のいくつかの重要なフィールドについて説明します:

(TIL:「binprm」はbinary programの略です。)

さて、この「buf」バッファを詳しく見てみましょう:

linux_binprm @ include/linux/binfmts.h
	char buf[BINPRM_BUF_SIZE];

見てわかる通り、その長さは定数 BINPRM_BUF_SIZE として定義されています。この文字列をコードベースで検索することによって、include/uapi/linux/binfmts.h 内でこの定義を見つけることができます:

include/uapi/linux/binfmts.h
/* sizeof(linux_binprm->buf) */
#define BINPRM_BUF_SIZE 256

したがって、カーネルは実行ファイルの最初の256バイトをこのメモリバッファに読み込みます。

傍注: UAPIとは何ですか?

上記のコードのパスに/uapi/が含まれていることに気付くかもしれません。なぜlinux_binprm構造体と同じファイルで長さが定義されていないのでしょうか、include/linux/binfmts.hに?

UAPIは「ユーザースペースAPI」の略です。この場合、これはバッファの長さがカーネルのパブリックAPIの一部であるべきだという誰かの判断を意味します。理論的には、UAPIのすべてがユーザーランドに公開され、非UAPIのすべてがカーネルコードに対してプライベートです。

カーネルとユーザースペースのコードは元々一つの混沌とした質量で共存していました。2012年に、UAPIコードは別のディレクトリにリファクタリングされました。これは保守性を向上させる試みでした。

ステップ2: Binfmts

カーネルの次の主要な役割は、いくつかの「binfmt」(バイナリフォーマット)ハンドラを繰り返し処理することです。これらのハンドラは、fs/binfmt_elf.cfs/binfmt_flat.cなどのファイルで定義されています。カーネルモジュールも、独自のbinfmtハンドラをプールに追加できます。

各ハンドラは、linux_binprm構造体を受け取り、ハンドラがプログラムのフォーマットを理解するかどうかを確認する load_binary() 関数を公開しています。

これは通常、バッファ内のマジックナンバーを探し、プログラムの開始部分をバッファからデコードしようとすること、および/またはファイル拡張子を確認することを含みます。ハンドラがそのフォーマットをサポートしている場合、プログラムを実行する準備をし、成功コードを返します。それ以外の場合、早期に終了し、エラーコードを返します。

カーネルは、成功するまで各binfmtの load_binary() 関数を試行します。これらは時々再帰的に実行されることがあります。例えば、スクリプトにインタープリタが指定されており、そのインタープリタ自体がスクリプトである場合、階層は binfmt_script > binfmt_script > binfmt_elf となる可能性があります(ここでELFはチェーンの最後にある実行可能なフォーマットです)。

フォーマットの強調: スクリプト

Linuxがサポートする多くのフォーマットの中で、binfmt_script が最初に具体的に話したいものです。

シバン(Unixのシバン)を読んだことがありますか?いくつかのスクリプトの先頭にある、インタプリタのパスを指定する行のことです。

#!/bin/bash

私はいつもこれらはシェルで処理されていると思っていましたが、実はそうではありませんでした!シェバング(シェバン行)は実際にはカーネルの機能であり、スクリプトは他のすべてのプログラムと同じシスコールを使用して実行されます。コンピュータは本当にクールですね。

fs/binfmt_script.c がファイルがシェバングで始まるかどうかをチェックする方法を見てみましょう:

load_script @ fs/binfmt_script.c
	/* Not ours to exec if we don't start with "#!". */
	if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
		return -ENOEXEC;

もしファイルがシバン行で始まっている場合、binfmt ハンドラはその後、インタープリタのパスと、パスの後に空白で区切られた引数を読み取ります。新しい行またはバッファの末尾に達するまで読み取りを続けます。

ここで面白い、ちょっと奇妙な2つのことが起こっています。

まず第一に、ファイルの最初の256バイトで埋められた linux_binprm のバッファを覚えていますか?それは実行可能なフォーマットの検出に使用されますが、同じバッファが binfmt_script でシバン行を読み取るためにも使用されます。

私の研究中に、そのバッファが128バイトであると説明した記事を読みました。その記事が公開された後のある時点で、その長さは256バイトに倍増されました!なぜそうなったのか、私はLinuxソースコードで BINPRM_BUF_SIZE が定義されている行のGit blame(特定のコード行を誰が編集したかを示すログ)を確認しました。すると…

Visual Studio Code エディタからの Git blame ウィンドウのスクリーンショット。git blame には "#define BINPRM_BUF_SIZE 128" という行が 256 に変更されるのが表示されています。コミットは Oleg Nesterov によるもので、主要なテキストには "exec: increase BINPRM_BUF_SIZE to 256. Large enterprise clients often run applications out of networked file systems where the IT mandated layout of project volumes can end up leading to paths that are longer than 128 characters.  Bumping this up to the next order of two solves this problem in all but the most egregious case while still fitting into a 512b slab." と書かれています。コミットには Linus Torvalds も署名しています。

コンピュータは本当にすごいですね!

シバン行はカーネルによって処理され、ファイル全体を読み込むのではなく buf から取得されるため、常に buf の長さに切り詰められます。おそらく4年前、カーネルが128文字を超えるパスを切り詰めることにイライラした誰かが、バッファのサイズを倍にすることで切り詰めポイントを倍にした解決策を見つけました!今日、あなた自身のLinuxマシンで256文字を超えるシバン行がある場合、256文字を超える部分は完全に失われてしまいます

シバン行の切り詰めを示す図。ファイル名がfile.binのファイルからの大きなバイト配列があります。最初の256バイトが強調表示され、「bufに読み込まれた」とラベルが付けられています。残りのバイトは透明で、「256バイトを超えたところ」とラベルが付けられています。

これによるバグを持っていると想像してみてください。コードが壊れている原因を特定しようとすることを想像してみてください。問題がLinuxカーネルの奥深くにあることを発見すると、どのような気持ちになるでしょうか。巨大な企業で次のIT担当者が、パスの一部が謎のように消えてしまったことに気付いたときの気持ちを想像してみてください。

もうひとつの不思議なこと: argv[0]がプログラム名であることは慣例であることを覚えていますか?呼び出し元はexecシスコールに任意のargvを渡すことができ、それは無修正で渡されます。

たまたま、binfmt_scriptargv[0]がプログラム名であると仮定している場所の一つです。常にargv[0]を削除し、次のものをargvの先頭に追加します:

例: 引数の変更

サンプルの execve 呼び出しを見てみましょう:

// Arguments: filename, argv, envp
execve("./script", [ "A", "B", "C" ], []);

この仮想的な script ファイルは、最初の行に以下のシバンを持っています:

script
#!/usr/bin/node --experimental-module

変更された argv が最終的にNodeインタープリターに渡されるでしょう:

[ "/usr/bin/node", "--experimental-module", "./script", "B", "C" ]

argvを更新した後、ハンドラーは、linux_binprm.interpをインタープリターパス(この場合、Nodeバイナリ)に設定して、ファイルを実行する準備を完了します。最後に、プログラムの実行の準備が成功したことを示すために0を返します。

フォーマットハイライト: その他のインタープリタ

もう一つ興味深いハンドラは binfmt_misc です。これは、特別なファイルシステムを /proc/sys/fs/binfmt_misc/ にマウントすることで、ユーザーランドの設定を介して一部の限定的なフォーマットを追加できる機能を提供します。プログラムは、このディレクトリ内のファイルに 特別な形式 の書き込みを行うことで、独自のハンドラを追加できます。各設定エントリは以下を指定します:

この binfmt_misc システムは、多くの場合、Java インストールで使用され、その設定では 0xCAFEBABE マジックバイトによってクラスファイルと、拡張子によって JAR ファイルを検出するように構成されています。私の特定のシステムでは、Python バイトコードをその .pyc 拡張子で検出し、適切なハンドラに渡すように構成されています。

これは、プログラムのインストーラが高度な特権を持つカーネルコードを書かなくても、独自のフォーマットのサポートを追加できる素晴らしい方法です。

最終的に

execシステムコールは常に次の2つのパスのいずれかに到達します:

Unixのようなシステムを使用したことがある場合、シェルスクリプトにシバン行または.sh拡張子がない場合でも、ターミナルから実行されることに気付いたかもしれません。現在非Windowsのターミナルが利用可能であれば、これを試すことができます:

Shell session
$ echo "echo hello" > ./file
$ chmod +x ./file
$ ./file
hello

(chmod +xはOSにファイルが実行可能であることを伝えるものです。それ以外の場合、ファイルを実行できません。)

では、なぜシェルスクリプトはシェルスクリプトとして実行されるのでしょうか? カーネルのフォーマットハンドラには、識別可能なラベルがない状態でシェルスクリプトを検出する明確な方法がないはずです!

実際、この動作はカーネルの一部ではないことが判明しました。これは、一般的にはシェルが失敗した場合の処理方法です。

ファイルをシェルを使って実行し、exec シスコールが失敗した場合、ほとんどのシェルは、ファイルをシェルスクリプトとして再試行することがあります。これは、ファイル名を最初の引数として持つシェルを実行することで行われます。Bashは通常、これを自分自身の解釈器として使用しますが、ZSHは通常、Bourneシェルとして知られるshを使用します。

この動作は、Unixシステム間でコードを移植可能にするために設計された古い標準であるPOSIXで指定されているため、一般的です。POSIXはほとんどのツールやオペレーティングシステムに厳密に従われていないものの、その多くの規約が共有されています。

もし[exec シスコール]が[ENOEXEC]エラーに相当するエラーのために失敗した場合、シェルはコマンド名を最初のオペランドとして持つシェルを呼び出した状態でのコマンドを実行します。残りの引数は新しいシェルに渡されます。実行可能ファイルがテキストファイルでない場合、シェルはこのコマンドの実行をバイパスすることがあります。この場合、エラーメッセージを書き込み、終了ステータスを126で返します。

出典: Shell Command Language, POSIX.1-2017

コンピュータは本当にすごいですね!

Continue to Chapter 4: エルフ卿になる(ELF)