コース目次 / 第3章
安全でないデシリアライズ — 復元が、実行になる瞬間
設定を「元の形に戻す」処理をevalで書いたやられを、データの中にコードを仕込んで発火させます。原因を理解し、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章です。
この章はまだ完了していません。
保存できませんでした(プライベートブラウズ中かもしれません)。この端末に進捗は残りませんが、先へは進めます。