コース目次 / 第3章

安全でないデシリアライズ — 復元が、実行になる瞬間

設定を「元の形に戻す」処理をevalで書いたやられを、データの中にコードを仕込んで発火させます。原因を理解し、JSON.parseとスキーマ検証で直して再検証します。

第3章 / 全5章目安 約12分この章のゴール: 危険な復元処理でコードが実行される様子を確かめ、JSON.parse+スキーマ検証で直せるようになる

保存したデータを、あとで元の形(オブジェクト)に戻す処理を、デシリアライズと呼びます。ここに、これまでの注入と同じ形の穴が潜みます——復元の過程で、データがコードとして解釈されてしまう。

法と倫理ゲート

手を動かす前に

このコースは攻撃の技術に触れます。実習してよい相手は、次の3つだけです。

  • (a) 自分で作った練習用のやられ環境(このサイトが配る自作ターゲット)
  • (b) あなた自身の許可された環境(自分の Thousand SKY・secret-notes-ctf など)
  • (c) CTF 競技の中(規約の範囲で)

実在する第三者のシステムを許可なく触ることは、不正アクセス禁止法に触れます。 このゲートは、その線引きを毎回の入口で確かめるためのものです。詳しくは「セキュリティの法と倫理」へ。

標的 — evalで「復元」する、設定読み込み

JSON.parse は安全にオブジェクトへ戻してくれますが、もっと柔軟に(コメント付き・関数付きのデータも読みたい、と)eval を使って復元しているコードが、現実にも存在します。それを再現します。vuln-restore.js として保存します。

// vuln-restore.js — 学習用「わざと危険な復元」を使う設定読み込み。127.0.0.1:3910。
const http = require('node:http');

http.createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1:3910');
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');

  if (url.pathname === '/restore' && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => (body += c));
    req.on('end', () => {
      try {
        // 【穴: 安全でないデシリアライズ】届いたデータを、JSON.parse ではなく
        // eval でオブジェクトに"復元"している(柔軟だが危険)。
        const config = eval('(' + body + ')');
        res.end('復元しました: ' + JSON.stringify(config));
      } catch (e) {
        res.end('復元エラー: ' + e.message);
      }
    });
    return;
  }
  res.end('POST /restore に、復元したいデータ(オブジェクトリテラル文字列)を送ってください');
}).listen(3910, '127.0.0.1', () => console.log('復元デモ: http://127.0.0.1:3910 (Ctrl+C で停止)'));
node vuln-restore.js

正規の使い方

普通の設定データを送ります。

curl -X POST --data '{theme:"dark",fontSize:14}' http://127.0.0.1:3910/restore

復元しました: {"theme":"dark","fontSize":14} と返ります。eval は、これをJavaScriptのオブジェクトリテラルとして評価して復元しました。ここまでは、動いているように見えます。

攻撃 — データの中に、コードを仕込む

eval は、渡された文字列をJavaScriptのコードとして実行します。オブジェクトリテラルの体裁を保ちながら、その場で何かを実行させる式を混ぜられます。

curl -X POST --data '{theme:(function(){ global.__pwned = true; return "dark"; })()}' http://127.0.0.1:3910/restore

これは「theme の値」を作るふりをして、即時実行される関数を仕込んでいます。この関数はここでは無害な印(global.__pwned = true)を立てるだけですが、この関数の中に書けることに制限はありません——サーバのプロセス上で、eval を呼んだのと同じ権限で、任意のコードが動きます。
確かめてみます——同じプロセスの中で global.__pwned が立ったかどうかは、次に送るリクエストで分かる作りにしておくと分かりやすいので、まずはこの結果自体に注目してください。復元エラーにならず、theme の値として “dark” が返ってきたなら、その裏で、あなたが仕込んだ関数が実際に実行されています。eval はデータを読んだのではなく、コードとして走らせたのです。

ここでは安全のため、印を立てるだけの無害な実演に留めています。しかし原理上、eval に届いた文字列の中でできることに壁はありません——ファイルの読み書き、外部への通信、他のプロセスの起動まで、Node プロセスが持つ権限のすべてが及びます。「データを復元しただけ」のつもりが、任意のコード実行(RCE)にそのままつながる、というのがこの穴の深刻さです。

修正 — JSON.parse と、スキーマ検証

直しは2段構えです。

1. eval をやめ、JSON.parse を使う。JSON.parse は、文字列をデータとしてのみ解釈します。関数呼び出しも、式の評価も一切できない——構文がJSONの形をしていなければ、ただ失敗するだけです。
2. 形(スキーマ)を検証する。JSON として読めても、「期待した形か」はまだ分かりません。theme は文字列か、fontSize は数値か——を確かめてから使います。

// 直したあと: JSON.parse で読み、期待する形かを確かめてから使う。
function restore(text) {
  const data = JSON.parse(text); // コードとしては一切評価されない
  if (typeof data !== 'object' || data === null) throw new Error('形が不正です');
  if (typeof data.theme !== 'string' || typeof data.fontSize !== 'number') {
    throw new Error('期待する項目がありません');
  }
  return { theme: data.theme, fontSize: data.fontSize }; // 検証済みの項目だけを使う
}

再検証 — もう一度、仕込んだコードを

eval を JSON.parse ベースの restore に置き換えて再起動し、同じ攻撃を送ります。

curl -X POST --data '{theme:(function(){ global.__pwned = true; return "dark"; })()}' http://127.0.0.1:3910/restore

今度は復元エラーになります。送った文字列は、正しい JSON({“theme”:“dark”…} のようにキーが引用符付きでなければならない)ではないので、JSON.parse はその時点で例外を投げます。関数もコードも、一切実行されません。データは、最初から最後までデータのままでした。

正規の JSON(curl -X POST --data '{"theme":"dark","fontSize":14}' ...)は、引き続き正しく復元されます。

持ち帰る一言

復元は、コードとして評価しない。 eval のような「柔軟な復元」は、データの中に紛れ込んだコードをそのまま実行してしまう危険と隣り合わせ。JSON.parse(データとしてのみ解釈)とスキーマ検証(形を確かめてから使う)で、根本から塞げます。これは、これまで見てきた注入(SQL・コマンド・SSTI)とまったく同じ形の穴でした。次はまとめて、サーバサイドの深い穴を締めます。

こうなっていればOK

卒業まであと1章です。

この章はまだ完了していません。