信頼できないデータのデシリアライズ

node-serializeへ信頼できないデータを渡すことによるコード実行

説明

node-serialize 0.0.4の unserialize は、通常のデータだけを復元する関数ではありません。文字列には最初に JSON.parse を適用しますが、その後オブジェクトや配列を再帰的にたどり、_$$ND_FUNC$$_ で始まる文字列プロパティを評価します。そのため、リクエストから受け取った文字列、オブジェクト、配列を渡すと、サーバープロセスで任意のJavaScriptを実行されるおそれがあります。

パッケージのセキュリティ勧告には修正版がありません。リクエスト処理から node-serialize と実行可能なオブジェクト復元を取り除き、外部入力をデータとしてだけ処理してください。

リスクの原因

  • unserialize は関数マーカーを単なる文字列ではなく、実行するコードとして解釈します。
  • 先に JSON.parse しても、その結果を unserialize に渡すとマーカー文字列が評価されるため、安全にはなりません。
  • 署名で検出できるのは許可されていない改ざんです。悪意のある正規の生成者、漏えいした鍵、汚染された既存レコード、コードを実行するデコーダーを安全にはできません。
  • コンテナーや最小権限は被害を抑える補助策です。Node.js Permission Modelは悪意のあるコードに対するセキュリティ境界ではありません。

想定される影響

  • アプリケーションの権限による任意のコードやコマンドの実行
  • ファイル、環境変数、認証情報、内部サービスへのアクセス
  • データの漏えい・改ざんや他のシステムへの侵害拡大
  • 悪意のあるレコードが保存された場合の繰り返しのコード実行

対処方法

  1. リクエスト処理コードと実行時の依存関係から node-serialize を除去してください。アップグレード先となる修正版はありません。
  2. 外部データはJSONなどのデータ専用形式で解析してください。解析前にエンドポイントに合うバイト数制限を適用し、解析後に必須フィールド、型、長さ・範囲、余分なフィールドを検証してください。TypeScriptのインターフェース、型注釈、ジェネリック制約、型アサーションは実行時に除去されるため、検証にはなりません。
  3. 検証済みのフィールドだけを新しいデータオブジェクトへコピーしてください。解析・検証した値を unserialize、eval、Function、動的な処理選択へ渡さないでください。
  4. 既存レコードはネットワークリクエストから分離した管理下のオフライン手順で、データ専用形式へ移行してください。一時的に旧レコードを復元する必要がある場合は、保存された正確なバイト列の真正性と生成者の権限を事前に確認してください。実行可能なデシリアライズを除去する恒久的な対策の代わりにはなりません。
  5. 移行処理を最小権限で実行し、完了後は検索とテストで unserialize の呼び出しと依存関係が除去されたことを確認してください。

例

変更前

javascript
const express = require("express");
let serialize = require("node-serialize");
const app = express();

app.use(express.json({ limit: "64kb", strict: true }));

app.post("/profiles", (req, res) => {
  const profile = serialize.unserialize(req.body);
  res.json(profile);
});

呼び出し元が本文へ関数マーカーを含められるため、文字列だけでなくオブジェクトの入力も危険です。

変更後

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

app.use(express.json({ limit: "64kb", strict: true }));

const allowedProfileKeys = new Set(["displayName", "age"]);

function parseProfile(body) {
  if (body === null || typeof body !== "object" || Array.isArray(body)) {
    throw new TypeError("profile must be an object");
  }

  const keys = Object.keys(body);
  if (
    keys.length !== 2 ||
    keys.some((key) => !allowedProfileKeys.has(key)) ||
    !Object.hasOwn(body, "displayName") ||
    !Object.hasOwn(body, "age")
  ) {
    throw new TypeError("profile has an invalid shape");
  }

  if (
    typeof body.displayName !== "string" ||
    body.displayName.length < 1 ||
    body.displayName.length > 80 ||
    !Number.isInteger(body.age) ||
    body.age < 0 ||
    body.age > 130
  ) {
    throw new TypeError("profile has invalid values");
  }

  return {
    displayName: body.displayName,
    age: body.age,
  };
}

app.post("/profiles", (req, res) => {
  try {
    const profile = parseProfile(req.body);
    res.status(201).json(profile);
  } catch {
    res.status(400).json({ error: "invalid profile" });
  }
});

リクエストサイズ、正確なデータ構造、用途に合う値の範囲を検証し、許可した値だけを新しいオブジェクトへコピーします。64kb やフィールド制約は例なので、実際のエンドポイントのデータ仕様に合わせて絞り込んでください。

参考資料