[C言語] exec系関数を使用してシェルコマンドを実行する

システムプログラミング

はじめに

C言語でプログラムを作成していると、時々、外部のプログラムやシェルコマンドを実行したい場面に遭遇します。例えば、ファイルの一覧を取得するために ls コマンドを実行したり、特定のテキストを検索するために grep コマンドを利用したり、あるいは自作の別のプログラムを呼び出したりする場合などです。

このような要求に応えるための方法として、C言語にはいくつかの手段が用意されています。その中でも、exec系の関数群は、現在のプロセスを新しいプロセスイメージで置き換えることによって、指定したプログラムを実行するための強力な機能を提供します。

system() 関数のように単純にコマンド文字列を実行する関数もありますが、exec系関数はより低レベルな操作を提供し、引数や環境変数を細かく制御できるという利点があります。ただし、exec系関数は現在のプロセスを置き換えてしまうため、通常は fork() システムコールと組み合わせて使用されます。

この記事では、C言語の exec 系関数に焦点を当て、その種類、使い方、そしてシェルコマンドを実行する際の注意点などを詳しく解説していきます。fork() と組み合わせた基本的な使い方から、セキュリティに関する考慮事項まで、幅広くカバーします。さあ、exec の世界を探検してみましょう!🚀

exec系関数とは?

exec 系関数は、UNIX系オペレーティングシステム(Linux、macOSなど)において、現在のプロセスイメージを新しいプロセスイメージで置き換える ためのシステムコール群です。これらの関数は <unistd.h> ヘッダファイルで宣言されています。

最大の特徴は、「置き換える」という点です。exec 系関数が成功すると、呼び出し元のプログラムのコード、データ、スタック、ヒープはすべて破棄され、指定された新しいプログラムがロードされて実行が開始されます。そのため、exec 系関数の呼び出しが成功した場合、元のプログラムには決して戻ってきません。制御が戻ってくるのは、exec の実行に失敗した場合(例えば、指定されたファイルが存在しない、実行権限がないなど)のみです。

この「戻ってこない」という性質のため、元のプログラムの処理を続けたい場合には、通常 fork() システムコールと組み合わせて使用します。fork() で子プロセスを作成し、その子プロセス内で exec 系関数を呼び出すことで、親プロセスは自身の実行を継続しつつ、子プロセスに新しいプログラムを実行させることができます。この fork()exec の組み合わせは、UNIX系OSにおけるプログラム実行の基本的なパターンです。

exec という名前の関数自体は存在せず、実際には引数の渡し方や環境変数の扱い、パス検索の有無などによって、以下のような複数のバリエーションが存在します。

  • execl()
  • execv()
  • execle()
  • execve()
  • execlp()
  • execvp()

これらの関数の違いを理解することが、exec 系関数を効果的に使いこなす鍵となります。次のセクションで、それぞれの関数の特徴を見ていきましょう。

exec系関数の種類と違い

exec 系関数にはいくつかのバリエーションがあり、関数名の末尾の文字によってその機能が異なります。主な違いは以下の3点です。

  • 引数の渡し方 (l or v): 新しいプログラムに渡すコマンドライン引数を、可変長引数リスト (l) で渡すか、文字列ポインタ配列 (v: vector) で渡すか。
  • 環境変数の指定 (e): 新しいプログラムの環境変数を独自に指定するか (e)、現在のプロセスの環境変数を引き継ぐか。
  • パス検索 (p): 実行するプログラムのパス名を完全なパスで指定する必要があるか、環境変数 PATH を検索してファイル名だけで指定できるか (p)。

これらの組み合わせによって、以下の関数が存在します。

関数名引数リスト環境変数PATH検索概要
execlリスト (l)引き継ぐなしパス名を指定し、引数を個々にリストで渡す。
execv配列 (v)引き継ぐなしパス名を指定し、引数を文字列ポインタ配列で渡す。
execleリスト (l)指定 (e)なしパス名を指定し、引数をリストで、環境変数を配列で渡す。
execve配列 (v)指定 (e)なしパス名を指定し、引数と環境変数を配列で渡す。(システムコール本体)
execlpリスト (l)引き継ぐあり (p)ファイル名を指定し (PATH検索あり)、引数をリストで渡す。
execvp配列 (v)引き継ぐあり (p)ファイル名を指定し (PATH検索あり)、引数を配列で渡す。

引数の渡し方 (l vs v)

関数名に l (list) が含まれる関数 (execl, execlp, execle) は、新しいプログラムに渡すコマンドライン引数を、NULL で終端された可変長引数リストとして直接指定します。これは、引数の数がコンパイル時にわかっている場合に便利です。

// execl の例: ls -l /tmp を実行
execl("/bin/ls", "ls", "-l", "/tmp", (char *)NULL);

一方、関数名に v (vector) が含まれる関数 (execv, execvp, execve) は、引数を NULL で終端された文字列ポインタの配列 (char *argv[]) として渡します。これは、引数の数が実行時に決まる場合や、動的に引数リストを構築する場合に適しています。

// execv の例: ls -l /tmp を実行
char *args[] = {"ls", "-l", "/tmp", NULL};
execv("/bin/ls", args);

どちらの形式でも、慣例として、引数リスト(または配列の最初の要素 argv[0])には実行するプログラム名自体を含める必要があります。

環境変数の指定 (e)

関数名に e (environment) が含まれる関数 (execle, execve) は、新しいプログラムに渡す環境変数を明示的に指定できます。環境変数は "変数名=値" という形式の文字列ポインタ配列 (char *envp[]) で渡し、配列の最後は NULL で終端します。

// execle の例: 環境変数 MYVAR=myvalue を設定して /usr/bin/env を実行
char *envp[] = {"MYVAR=myvalue", "PATH=/bin:/usr/bin", NULL};
execle("/usr/bin/env", "env", (char *)NULL, envp);

e が付かない関数は、呼び出し元プロセスの現在の環境変数をそのまま新しいプロセスに引き継ぎます。多くの場合はこちらで十分ですが、特定の環境変数を設定したり、逆に不要な環境変数を渡さないようにしたりしたい場合に e 付きの関数が役立ちます。execve は Linux カーネルが提供する実際のシステムコールであり、他の exec 関数は最終的に execve を呼び出すラッパー関数です。

パス検索 (p)

関数名に p (path) が含まれる関数 (execlp, execvp) は、実行するプログラム名を指定する際に、ファイル名だけを指定できます。これらの関数は、環境変数 PATH に設定されているディレクトリリストを順番に検索し、最初に見つかった実行可能ファイルを実行します。これは、シェルのコマンド検索と同じ挙動です。

// execlp の例: PATH を検索して ls を実行
execlp("ls", "ls", "-l", (char *)NULL);

p が付かない関数は、実行するプログラムのパスを完全なパス名(絶対パスまたは相対パス)で指定する必要があります。もし指定されたパスにファイルが存在しない場合、エラーとなります。

// execl の例: フルパスで ls を実行
execl("/bin/ls", "ls", "-l", (char *)NULL);

一般的には、lsgrep のような標準的なコマンドを実行する場合は p 付きの関数が便利ですが、特定の場所にある自作プログラムなどを実行する場合は p なしの関数でフルパスを指定する方が確実です。

exec系関数の使い方: fork() との組み合わせ

前述の通り、exec 系関数は現在のプロセスを置き換えてしまうため、単独で呼び出すと元のプログラムの処理はそこで終了してしまいます。元のプログラム(親プロセス)の処理を続けながら、別のプログラム(子プロセス)を実行したい場合、fork() システムコールと exec 系関数を組み合わせるのが定石です。

基本的な流れは以下のようになります。

  1. fork() の呼び出し: 親プロセスが fork() を呼び出して、自分自身のコピーである子プロセスを作成します。
  2. fork() の戻り値チェック:
    • fork() は親プロセスには子プロセスのプロセスID (PID) を返します。
    • 子プロセスには 0 を返します。
    • 失敗した場合は -1 を返します。
    この戻り値を使って、現在のプロセスが親か子かを判別します。
  3. 子プロセスの処理: fork() の戻り値が 0 だった場合、ここは子プロセスです。ここで exec 系関数を呼び出して、目的のプログラムを実行します。exec が成功すれば、子プロセスのコードはこの exec 呼び出し以降実行されません。もし exec が失敗した場合(指定したプログラムが見つからないなど)、エラーメッセージを表示して終了する処理を記述します (例: perror() を呼び出して exit() する)。
  4. 親プロセスの処理: fork() の戻り値が正の数 (子プロセスのPID) だった場合、ここは親プロセスです。親プロセスは通常、wait()waitpid() システムコールを使って、子プロセスの終了を待ちます。これにより、子プロセスが終了するまで親プロセスは待機し、子プロセスの終了ステータスを受け取ることができます。子プロセスの終了を待たずに他の処理を続けることも可能です(非同期実行)。
  5. エラー処理: fork()-1 を返した場合、子プロセスの作成に失敗しています。適切なエラー処理を行います。

サンプルコード (fork + execvp)

以下の例は、fork()execvp() を使って ls -l コマンドを実行する基本的なプログラムです。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h> // errno と perror のために追加

int main() {
    pid_t pid;
    char *args[] = {"ls", "-l", NULL}; // execvp に渡す引数配列

    printf("Parent process (PID: %d) starting...\n", getpid());

    // 子プロセスを生成
    pid = fork();

    if (pid < 0) {
        // fork 失敗
        perror("fork failed");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // --- ここは子プロセス ---
        printf("Child process (PID: %d) executing command...\n", getpid());

        // execvp で ls -l を実行
        // execvp は PATH 環境変数を検索して "ls" を見つける
        execvp(args[0], args);

        // execvp が成功した場合、以下のコードは実行されない
        // execvp が失敗した場合のみ、ここに来る
        perror("execvp failed"); // エラー内容を表示
        exit(EXIT_FAILURE); // エラー終了
    } else {
        // --- ここは親プロセス ---
        printf("Parent process (PID: %d) waiting for child (PID: %d)...\n", getpid(), pid);

        int status;
        // 子プロセスの終了を待つ
        waitpid(pid, &status, 0);

        if (WIFEXITED(status)) {
            printf("Parent process: Child process (PID: %d) exited with status %d.\n", pid, WEXITSTATUS(status));
        } else {
            printf("Parent process: Child process (PID: %d) terminated abnormally.\n", pid);
        }

        printf("Parent process (PID: %d) finished.\n", getpid());
    }

    return 0; // 親プロセスのみがここに到達する
}

エラーハンドリングの重要性

fork()exec 系関数は失敗する可能性があります。fork() はシステムリソース(メモリやプロセス数上限)が不足している場合に失敗します。exec 系関数は、指定されたファイルが存在しない、実行権限がない、ファイル形式が不正などの理由で失敗します。

特に exec のエラーハンドリングは重要です。exec は成功すると戻ってこないため、exec の呼び出し直後にエラー処理コードを書く必要があります。もし exec が失敗した場合、そのコードが実行されます。一般的には perror() 関数でエラー内容を標準エラー出力に表示し、exit()_exit() で子プロセスを異常終了させるのが適切です。perror() は現在の errno の値に基づいて、エラーメッセージ(例: “No such file or directory”, “Permission denied”)を表示してくれます。

親プロセス側では、wait()waitpid() の戻り値やステータス情報を確認することで、子プロセスが正常に終了したか、あるいはエラーで終了したかを判断できます。マクロ WIFEXITED(status) で正常終了か、WEXITSTATUS(status) で正常終了時の終了コードを取得できます。

シェルコマンドの実行と注意点

exec 系関数は、特定の実行可能ファイルを直接実行するためのものですが、これを使ってシェルコマンドを実行することも可能です。ただし、いくつかの注意点があります。

単純なコマンドの実行

lspwd のような、単一の実行可能ファイルに対応する単純なコマンドは、exec 系関数で直接実行できます。p 付きの関数(execlp, execvp)を使えば、PATH を通じてコマンドを見つけてくれます。

// pwd コマンドを実行する例 (子プロセス内)
execlp("pwd", "pwd", (char *)NULL);
// エラー処理は省略

シェル機能が必要なコマンド

パイプ (|)、リダイレクション (>, <)、ワイルドカード (*, ?)、環境変数展開 ($VAR)、コマンド置換 (`command` または $(command))、複数のコマンドの連結 (;, &&, ||) など、シェルの機能に依存するコマンドを実行したい場合は、exec で直接コマンドを実行するだけでは不十分です。

このような場合は、シェル自体 (通常は /bin/sh) を exec で起動し、実行したいコマンド文字列をシェルに引数として渡す必要があります。シェルを起動する際の一般的な方法は、-c オプションを使用することです。

// "ls -l | grep .c" を実行する例 (子プロセス内)
execl("/bin/sh", "sh", "-c", "ls -l | grep .c", (char *)NULL);
// エラー処理は省略

この例では、/bin/sh を実行し、その引数として sh (慣例的な argv[0])、-c (次の引数をコマンドとして解釈・実行させるオプション)、そして実行したいコマンド文字列 "ls -l | grep .c" を渡しています。最後の引数は NULL です。

このようにシェルを介することで、パイプやリダイレクションなどのシェル機能を活用できます。

system() 関数との比較

シェルコマンドを実行する最も簡単な方法は、標準ライブラリ関数である system() を使うことです。

#include <stdlib.h>
#include <stdio.h>

int main() {
    int ret = system("ls -l | grep .c");
    if (ret == -1) {
        perror("system failed");
    }
    return ret;
}

system() 関数は、内部で fork()exec() (通常は execl("/bin/sh", "sh", "-c", command, (char *)NULL); のような形)、waitpid() を呼び出しており、指定されたコマンド文字列をシェルに渡して実行し、その終了を待ちます。

system()fork() + exec() の使い分けは以下の点を考慮します。

  • 手軽さ: system() は非常に手軽です。複雑なシェル機能を簡単に利用できます。
  • 制御: fork() + exec() は、引数、環境変数、ファイルディスクリプタ (標準入出力のリダイレクトなど) をより細かく制御できます。system() では難しい、実行するコマンドの標準入出力を親プロセスに接続するような処理も可能です (pipe() と組み合わせる)。
  • 効率: 単純なコマンド (シェル機能不要) を実行する場合、fork() + exec() で直接コマンドを実行する方が、system() のように毎回シェルを起動するよりも効率が良い場合があります。
  • セキュリティ: これが最も重要な違いの一つです。system() は渡された文字列をシェルが解釈するため、コマンドインジェクションの脆弱性を生みやすいです。一方、fork() + exec() では、引数を個別に渡すため(特に v 系関数)、適切に使えばより安全です。

セキュリティ上の注意: コマンドインジェクション

外部(ユーザー入力、ファイル、ネットワークなど)から受け取った文字列を元に実行するコマンドを組み立てる場合、細心の注意が必要です。特に system() 関数や、exec 系関数でシェル (/bin/sh -c) を呼び出す場合、悪意のある入力によって意図しないコマンドが実行されてしまう「コマンドインジェクション」攻撃を受ける可能性があります。

警告: 例えば、ユーザーに入力されたファイル名を削除するコマンドを system() で実行する場合を考えます。
char filename[256];
// ユーザー入力を filename に読み込む (例: scanf("%s", filename);)
char command[512];
snprintf(command, sizeof(command), "rm %s", filename);
system(command); // 危険!
もしユーザーがファイル名として my_file; rm -rf / のような文字列を入力した場合、シェルはこれを rm my_filerm -rf / の2つのコマンドとして解釈し、システムに壊滅的なダメージを与える可能性があります 😱。

このような脆弱性を避けるためには、以下の対策が考えられます。

  • system() やシェル (sh -c) の使用を避ける: 可能であれば、コマンドインジェクションのリスクがない exec 系関数 (v 系が望ましい) を直接使い、引数を安全に渡します。
  • 入力の検証と無害化 (Sanitization): どうしてもシェル機能が必要な場合や system() を使う必要がある場合は、外部からの入力を厳格に検証し、シェルにとって特別な意味を持つ文字 (;, |, &, `, $, (, ), <, >, ', ", \ など) を適切にエスケープするか、許可された文字のみで構成されていることを確認します。しかし、完全な無害化は非常に困難です。
  • 最小権限の原則: プログラムを必要最小限の権限で実行します。

原則として、外部からの信頼できない入力をそのままコマンド文字列に埋め込んで system()sh -c に渡すべきではありません。 代わりに execvexecvp を使い、入力データをコマンドの引数として個別に渡す方がはるかに安全です。

// 安全な例 (子プロセス内)
char *filename_from_user = /* ユーザーからの入力 */;
char *args[] = {"rm", filename_from_user, NULL};
execvp("rm", args); // "rm" コマンドに filename_from_user を引数として渡す
// エラー処理

この方法では、filename_from_user の内容がシェルによって解釈されることはなく、単なる rm コマンドの引数として扱われるため、コマンドインジェクションは発生しません。

まとめ

この記事では、C言語の exec 系関数を使って外部プログラムやシェルコマンドを実行する方法について解説しました。

主なポイント:

  • exec 系関数は、現在のプロセスイメージを新しいプログラムで置き換えます。成功すると元のプログラムには戻りません。
  • l/v (引数リスト/配列)、e (環境変数指定)、p (PATH検索) の組み合わせで様々なバリエーション (execl, execv, execlp, execvp, execle, execve) があります。
  • 通常、元のプロセスの処理を継続するために fork() と組み合わせて使用され、子プロセスで exec を呼び出します。
  • シェル機能 (パイプ、リダイレクション等) を使いたい場合は、/bin/sh -c "command string" のようにシェル自体を exec で起動します。
  • system() 関数はシェルコマンドを手軽に実行できますが、コマンドインジェクションのリスクが高いため、外部入力を扱う際は注意が必要です。
  • セキュリティのため、可能な限り system()sh -c を避け、execvexecvp で引数を安全に渡す方法を推奨します。
  • fork(), exec, waitpid() などの呼び出しでは、エラーハンドリングが重要です。perror() を活用しましょう。

exec 系関数は、プロセスの挙動を細かく制御できる強力なツールです。fork() と組み合わせたパターンを理解し、セキュリティに配慮して使用することで、Cプログラムから外部コマンドを安全かつ効果的に実行できるようになります。ぜひ、これらの関数を活用して、より柔軟でパワフルなプログラムを作成してみてください! 💪

参考情報

コメント

タイトルとURLをコピーしました