Zip Slip

Zip Slip(アーカイブ展開時のパストラバーサル)

説明

Zip Slipは、アーカイブの entryName や fileName を検証せずに出力パスとして使うことで発生します。攻撃者が ../ や C:\、/tmp などの絶対パスを使うと、意図した展開先の外へ書き込まれる可能性があります。書き込み先とアプリケーションの権限によっては、実行ファイルの配置や設定の上書きにつながり、サービスに影響します。

想定される影響

  • アプリケーションの権限でのファイルの作成や上書き
  • Webルートや起動時のスクリプトなど、展開したファイルが後で実行される場合のコード実行
  • 設定、バイナリ、ログの上書きによる障害
  • アプリケーションのデータやログの改ざん

対処方法

  • エントリのパスを正規化し、絶対パスや親ディレクトリの要素を拒否してください。path.resolve で求めた最終パスが展開先の内部にあるか、path.relative で確認してください。
  • C:\ などのWindowsドライブ接頭辞を拒否してください。ZIPの外部属性からUNIXのファイル形式を確認し、シンボリックリンクのエントリを除外してください。
  • 必要な拡張子やディレクトリだけを許可リストで選んでください。
  • 信頼できる親ディレクトリの下に、新しい専用の非公開ディレクトリを作ってください。ディレクトリの権限を制限し、ファイルは wx で作成して上書きを防いでください。
  • ライブラリにエントリのフィルターやシンボリックリンク対策があれば有効にしてください。

例

変更前

javascript
// 変更前: エントリのパスをそのまま使って展開します(Zip Slip)。
const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");

function extractUnsafe(zipPath, destDir) {
  const zip = new AdmZip(zipPath);
  zip.getEntries().forEach((entry) => {
    const outPath = path.join(destDir, entry.entryName); // 検証なし
    const data = entry.getData();
    // entry.entryNameが"../../../../var/www/html/shell.js"なら展開先を脱出できます。
    fs.mkdirSync(path.dirname(outPath), { recursive: true });
    fs.writeFileSync(outPath, data); // 制限されていないパスにファイルを作成・上書きします。
  });
}

変更後

javascript
// 変更後: 新しい専用ディレクトリでエントリ形式と最終パスを検証します。
const fs = require("fs");
const path = require("path");
const AdmZip = require("adm-zip");

function isUnixSymlink(entry) {
  // ZIP外部属性の上位16ビットにはUNIXのファイルモードが入ります。
  const unixMode = entry.header.attr >>> 16;
  return (unixMode & 0o170000) === 0o120000;
}

function extractSafe(zipPath, destParent) {
  const zip = new AdmZip(zipPath);
  const parentReal = fs.realpathSync(destParent);
  const destReal = fs.mkdtempSync(path.join(parentReal, "extract-"));
  fs.chmodSync(destReal, 0o700);

  for (const entry of zip.getEntries()) {
    // 1) UNIXのシンボリックリンクを拒否します。
    if (isUnixSymlink(entry)) continue;

    // 2) パスを正規化します。
    const normalized = path.normalize(entry.entryName);

    // 3) 空・絶対パス、ドライブ接頭辞、親ディレクトリの要素を拒否します。
    if (normalized === "" || normalized === ".") continue;
    if (path.isAbsolute(normalized)) continue;
    if (normalized.split(path.sep).includes("..")) continue;
    if (/^[A-Za-z]:/.test(normalized)) continue;

    // 4) 最終パスを解決し、展開先の内部か確認します。
    const targetPath = path.resolve(destReal, normalized);
    if (
      path.relative(destReal, targetPath) !== ".." &&
      !path.relative(destReal, targetPath).startsWith(".." + path.sep) &&
      !path.isAbsolute(path.relative(destReal, targetPath))
    ) {
      if (entry.isDirectory) {
        fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 });
        continue;
      }

      // 5) 新しい専用ディレクトリ内だけに作成し、既存ファイルを上書きしません。
      fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
      const data = entry.getData();
      fs.writeFileSync(targetPath, data, { flag: "wx", mode: 0o600 });
    }
  }

  return destReal;
}

説明:

  • 変更前: 未検証の entryName を path.join で結合すると、../ を含むエントリが展開先を脱出し、ファイルを作成・上書きする可能性があります。
  • 変更後: 信頼する親の下に新しい専用ディレクトリを作り、UNIXのシンボリックリンクを拒否します。名前を正規化し、絶対パス、ドライブ接頭辞、親ディレクトリの要素を拒否したうえで、path.resolve と path.relative で範囲を確認します。許可したファイルは wx で作成し、既存ファイルを上書きしません。

参考資料