パストラバーサル

C/C++のファイルパストラバーサル

説明

外部入力をファイルシステムAPIのパス引数に渡すと、攻撃者が .. や絶対パスを使って、想定する基準ディレクトリの外にあるファイルを選択できる場合があります。Windowsでは、\ と /、ドライブ・UNC・デバイスのパス形式も考慮が必要です。シンボリックリンクやWindowsの再解析ポイントが別の場所へつながっていると、文字列だけを検証したパスも実際には基準ディレクトリの外を指す可能性があります。

basename() とC++の std::filesystem::path::filename() は、最後のパス要素を返すだけです。入力が . や .. の場合や、返された名前が任意のローカルファイルやリンクを選ぶ場合もあるため、汎用のパス無害化関数ではありません。lexically_normal() はファイルシステムを参照せず、文字列を変換します。canonical() と weakly_canonical() も、信頼できる基準ディレクトリ内にあることや、検証と使用の原子性を単独では保証しません。文字列の前方一致では /var/app/data-escape を /var/app/data 内と誤判定することがあり、開く前にパスが変更される競合も防げません。

想定される影響

  • プロセスの権限でのファイルの読み取り、作成、置換、名前変更、削除
  • 設定、認証情報、鍵、ソースコードの漏えいや改ざん
  • 高い権限のプロセスでは、権限昇格、コード実行、サービス停止

対処方法

外部入力をパスとして解釈しない設計を優先します。たとえば profile などの限定した識別子を許可リストで検証し、サーバーが管理する固定パスに対応付けます。

入力の一部をパスに使う必要がある場合は、次の対策を組み合わせます。

  1. 信頼できる基準ディレクトリをコードや保護された設定で固定し、絶対パス、空の要素、. と .. の要素を拒否します。
  2. 対象OSの規則でパスを正規化します。基準パスとの一致、または基準パスの直後のディレクトリ区切りを確認し、単純な strncmp() による前方一致は使いません。
  3. シンボリックリンク、ハードリンク、マウントポイント、Windowsの再解析ポイント、検証から使用までの変更を考慮します。可能なら、文字列を検証してから開き直すのではなく、ディレクトリのハンドルやファイル記述子に基づいて探索とオープンを制限します。
  4. Linux 5.6以降では、信頼できるディレクトリのファイル記述子と、openat2() の RESOLVE_BENEATH または RESOLVE_IN_ROOT を使います。マジックリンクを拒否するには RESOLVE_NO_MAGICLINKS、すべてのシンボリックリンクを拒否するには RESOLVE_NO_SYMLINKS を併用し、すべてのエラーを処理します。
  5. POSIXの openat() だけでは .. による脱出を防げません。openat2() が使えない場合は、拒否リストではなく厳密な形式で各要素を検証し、信頼できるディレクトリ記述子から各要素を O_NOFOLLOW でたどる方法を検討します。
  6. Windowsでは、CreateFile() の再解析ポイントの動作を考慮します。既存ファイルの読み取りでは、作成や切り詰めを行わず最小限のアクセス権で開き、データを使う前に GetFinalPathNameByHandle() などでハンドルを検証します。作成・置換には固定パスへの対応付けやプラットフォームに適した安全な設計を使い、検証前に破壊的なオープンを行わないでください。最終パスの取得だけでは包含関係を強制できないため、ボリューム表現とパス要素の境界を一貫した方法で比較します。
  7. 操作に必要な最小限の権限を与え、想定外のファイル形式、所有者、権限を拒否します。

realpath() は、既存パスの .、..、シンボリックリンクを解決するのに役立ちますが、基準ディレクトリ内の確認や競合の防止までは行いません。新規ファイルでは、親ディレクトリと最後の要素を別々に安全に扱う必要があります。

例

Cの例

変更前

c
#include <stdio.h>

int main(int argc, char **argv) {
    if (argc != 2) {
        return 2;
    }

    FILE *file = fopen(argv[1], "rb");
    if (file == NULL) {
        return 1;
    }
    return fclose(file);
}

変更後

c
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
    if (argc != 2) {
        return 2;
    }

    const char *path = NULL;
    if (strcmp(argv[1], "profile") == 0) {
        path = "/var/app/data/profile.json";
    } else if (strcmp(argv[1], "settings") == 0) {
        path = "/var/app/data/settings.json";
    } else {
        return 2;
    }

    FILE *file = fopen(path, "rb");
    if (file == NULL) {
        return 1;
    }
    return fclose(file);
}

説明:

  • 変更前: 外部入力をファイルパスとして直接渡しています。
  • 変更後: 入力をパスとして解釈せず、サーバーが管理するキーと固定パスの許可リストに対応付けます。絶対パスや .. の要素はファイルAPIに届きません。

C++の例

変更前

cpp
#include <fstream>

int main(int argc, char **argv) {
    if (argc != 2) {
        return 2;
    }

    std::ifstream file{argv[1]};
    return file ? 0 : 1;
}

変更後

cpp
#include <fstream>
#include <string_view>

int main(int argc, char **argv) {
    if (argc != 2) {
        return 2;
    }

    const char *path = nullptr;
    const std::string_view key{argv[1]};
    if (key == "profile") {
        path = "/var/app/data/profile.json";
    } else if (key == "settings") {
        path = "/var/app/data/settings.json";
    } else {
        return 2;
    }

    std::ifstream file{path};
    return file ? 0 : 1;
}

説明:

  • 変更前: argv[1] を std::ifstream のパス引数に直接渡すため、絶対パスや親ディレクトリの要素を指定できます。
  • 変更後: 入力は2つの識別子とだけ比較し、実際のパスは対応する固定の定数から選びます。std::filesystem::path::filename() という名前や、正規化だけで安全と判断しないでください。

参考資料