パラメーターの型の取り違えによるパス検証の回避

パラメーターの型の取り違えによるパス検証の回避

説明

ExpressなどのNode.jsサーバーでは、同じクエリパラメーターを繰り返すと、文字列ではなく配列として解析される場合があります。入力を文字列と仮定して indexOf や includes でパストラバーサルを検査すると、配列との動作の違いで検証を回避されるおそれがあります。その値を後で path.resolve やファイルの応答に使うと、パスの検証が機能しない場合があります。

想定される影響

  • 重複パラメーターで検証を回避され、制限ディレクトリ外のファイルにアクセスされる可能性があります。
  • 文字列向けの検証が、配列やオブジェクトなどでは想定外の動作をする場合があります。
  • パストラバーサル対策の回避により、機密ファイルの漏えいや意図しないファイルアクセスにつながるおそれがあります。

対処方法

  • パスの検証前に typeof value === "string" を確認するか、Array.isArray(value) で配列を拒否してください。
  • 絶対パスを拒否し、path.resolve で求めた最終パスが基準ディレクトリ内かどうかを path.relative で確認してください。
  • シンボリックリンクが存在する可能性がある場合は、realpath で解決した実際のファイルパスも同じディレクトリ内にあることを確認してください。
  • 検証から使用までにパスを変えられないよう、攻撃者が基準ディレクトリを変更できないようにしてください。
  • 可能なら入力されたパスではなく、サーバーが発行したファイルIDと許可リストの対応付けを使ってください。

例

変更前

javascript
const express = require("express");
const app = express();
const path = require("path");

app.get("/download", (req, res) => {
  const file = req.query.file;
  if (file.indexOf("..") !== -1) {
    return res.status(400).send("Bad request");
  }

  const selectedFile = Array.isArray(file) ? file[file.length - 1] : file;
  return res.sendFile(path.resolve("/srv/public", selectedFile));
});

変更後

javascript
const express = require("express");
const app = express();
const fs = require("fs");
const path = require("path");

const publicDir = fs.realpathSync("/srv/public");

function isInside(baseDir, targetPath) {
  const relative = path.relative(baseDir, targetPath);
  return (
    relative !== "" &&
    relative !== ".." &&
    !relative.startsWith(".." + path.sep) &&
    !path.isAbsolute(relative)
  );
}

app.get("/download", (req, res) => {
  const file = req.query.file;
  if (typeof file !== "string" || path.isAbsolute(file)) {
    return res.status(400).send("Bad request");
  }

  const candidate = path.resolve(publicDir, file);
  if (!isInside(publicDir, candidate)) {
    return res.status(400).send("Bad request");
  }

  let realFile;
  try {
    realFile = fs.realpathSync(candidate);
  } catch {
    return res.status(404).send("Not found");
  }

  if (!isInside(publicDir, realFile)) {
    return res.status(400).send("Bad request");
  }
  return res.sendFile(realFile);
});

説明:

  • 変更前: 入力を文字列と仮定しているため、配列などでは検証の意味が変わる場合があります。
  • 変更後: 攻撃者が変更できない基準ディレクトリを使い、文字列以外の値と絶対パスを先に拒否します。正規化したパスとシンボリックリンクを解決した実際のパスが、どちらも基準ディレクトリ内にあることを確認してからファイルを送ります。

攻撃例:

Expressのクエリ解析では、同じ名前のパラメーターを繰り返すと、値が配列になる場合があります。このとき file.indexOf("..") は部分文字列ではなく、配列の要素を検索します。".." と完全一致する要素がなければ検査を通過し、後続のコードが最後の要素をファイル名に使うと、パストラバーサルの文字列がそのまま渡されます。

bash
curl "http://localhost:3000/download?file=report.txt&file=../../../../etc/passwd"

このリクエストでは、req.query.file は次のような配列になる場合があります。

javascript
["report.txt", "../../../../etc/passwd"]

file.indexOf("..") は -1 を返すため、リクエストを拒否できません。selectedFile は "../../../../etc/passwd" となり、制限ディレクトリ外のファイルを要求できる場合があります。

参考資料