配列インジェクション対策

配列インジェクションとは「文字列が送られてくる前提の入力欄」に対し意図的に配列形式のデータを送信しエラーを誘発する攻撃手法の1つです。今回は実際の運用中に遭遇したケースを例に発生したエラーやその対策についてご紹介します。

配列インジェクションとは「文字列が送られてくる前提の入力欄」に対し意図的に配列形式のデータを送信しエラーを誘発する攻撃手法の1つです。今回は実際の運用中に遭遇したケースを例に発生したエラーやその対策についてご紹介します。

配列インジェクション

まず、例を用いて配列インジェクションによって誘発されたエラーについてご紹介します。以下の様な郵便番号の入力欄があるフォームがあるとします。

スクリーンショット

フォームを送信するとFormRequestクラスにてmailcodeの値を検証します。以下では必須チェックと入力値がXXX-XXXX形式かチェックを行っています。


namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class TestRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'mailcode' => ['required', function ($attr, $val, $fail) {
                if (! preg_match('/^\d{3}-\d{4}$/', $val)) {
                    $fail('郵便番号はXXX-XXXXの形式で入力してください。');
                }
            }],
        ];
    }
}

普通の操作であればmailcodeには文字列が送信されるはずですが、

<input type="text" name="mailcode"  value="{{ old('mailcode') }}" class="form-control placeholder="例: 123-4567">

攻撃者により、

<input type="text" name="mailcode[]"  value="{{ old('mailcode') }}" class="form-control placeholder="例: 123-4567">

mailcode[]=...の配列の形で書き換えられてPOST送信されたとします。(ブラウザの開発ツール等から書き換え可能です)すると以下のTypeErrorが発生します。 スクリーンショット2

対応1:文字列チェックの追加

先ほどのエラーの原因はシンプルです。Closureで定義したruleで使用しているpreg_match()の引数に配列が渡された為発生しました。よって、文字列チェックのルールstringを追加してみます。また、stringがfailしても後続のルールが実行されてしまう為、bailを追加してエラーが発生した時点で以降のチェックをストップさせましょう。

...
  // bail と string を追加。
  'mailcode' => ['bail', 'required', 'string', function ($attr, $val, $fail) {
      if (! preg_match('/^\d{3}-\d{4}$/', $val)) {
          $fail('郵便番号はXXX-XXXXの形式で入力してください。');
      }
  }],
...

また、エラー文言も以下のように設定しておきます。

    public function messages(): array
    {
        return [
            'mailcode.string' => '文字列以外が指定されました',
        ];
    }

この対策により、POST先(バリデーション実行箇所)での例外は発生しなくなりました。

余談ですが、Laravelのデフォルトのvalidation ruleのコードを閲覧するといずれも先に値の型を確認してから判定していることが分かります。例えば、以下はアルファベットか確認するalphaルールのソースコードです。preg_match()の前にis_string()にて文字列チェックを実施していますね。

    /**
     * Validate that an attribute contains only alphabetic characters.
     * If the 'ascii' option is passed, validate that an attribute contains only ascii alphabetic characters.
     *
     * @param  string  $attribute
     * @param  mixed  $value
     * @param  array<int, int|string>  $parameters
     * @return bool
     */
    public function validateAlpha($attribute, $value, $parameters)
    {
        if (isset($parameters[0]) && $parameters[0] === 'ascii') {
            return is_string($value) && preg_match('/\A[a-zA-Z]+\z/u', $value);
        }

        return is_string($value) && preg_match('/\A[\pL\pM]+\z/u', $value);
    }

今回のようにClosureやRuleクラスなどでカスタムルールを実装する際も同様にデータタイプをチェックすると攻撃を防御できます。

対応2:入力フォーム側の修正

前項の対応によりFormRequest側のエラーは解消しましたが、バリデーションエラーで元のページへリダイレクトする際に別のエラーが発生してしまいました。

スクリーンショット3

原因はblade側のinputタグで使用しているold('mailcode')にあります。多くのフォームはold()を使用して送信した値を再表示し、ユーザーが再入力する手間を省いたりエラーの原因を把握しやすくします。しかし、配列インジェクションが発生した場合、old('mailcode')で取得される値は配列となっており、{{ ... }}(内部的にはhtmlspecialchars())でTypeErrorとなります。そこで二次災害を防ぐ為にold()で際表示する前に型チェックを行い、string以外なら空文字に変換する事にします。具体的な実装方法として以下の2つの案があります。

  1. Blade側で old_string() のようなヘルパー関数を作成して old() と入れ替える
  2. FormRequest側でリクエスト値を上書きする

順番に見ていきましょう。

old_string()の実装例

こちらはStack Overflowでも紹介されていた対策です。(参考)自作のヘルパー関数old_string()を用意してold()と置き換えます。old_string()では値がstringの場合はそのまま表示し、それ以外の場合は空文字に変換します。早速実装してみましょう。まず、old_string()ヘルパーを作成します。自作のヘルパーを作成するにはapp/helpers.phpファイルを追加し、そこに以下のようにold_string()を定義します。

<?php

if (!f unction_exists('old_string')) {
    function old_string(string $key, string $default = ''): string
    {
        $val = old($key, $default);
        return is_string($val) ? $val : '';
    }
}

グローバルヘルパとして読み込ませる為にcomposer.jsonのautoloadに以下の設定を追加します。

    "autoload": {
        ...
        "files": [
            "app/helpers.php"
        ]
    },

設定を反映させる為に、dump-autoloadの実行を忘れずに。

composer dump-autoload

最後にblade側のold()old_string()に切り替えます。

<input type="text" name="mailcode"  value="{{ old_string('mailcode') }}" class="form-control placeholder=": 123-4567">

再度、配列インジェクションでエラーを発生させるとTypeErrorが発生せずエラー文言が表示されるはずです。

スクリーンショット4

対応3: FormRequest側で上書きする方法

こちらはFormRequestprepareForValidation()においてstring以外の型が渡されたら空文字に変換する方法です。やっている事はold_string()と同じです。まず、前項で変更したblade側のold_string()old()に戻しておきましょう。そしてTestRequestクラスにprepareForValidation()を追加し、以下のように実装してください。

    ...
    public function prepareForValidation()
    {
        if (! is_string($this->mailcode)) {
            request()->merge(['mailcode' => '']);
        }
    }
    ...

再度ブラウザの開発ツールから配列インジェクションを行うとTypeErrorが発生せずエラー文言が表示されるはずです。上記のコードを見て「おや?」と思った方がいるかもしれません。通常、prepareForValidation()において入力値を上書きする際は$this->merge()を使いますが、request()->merge()が使用されています。これには理由があります。

request()->merge()と$this->merge()の違い

$this->merge()FormRequest 内部でのみ有効な値のマージです。バリデーション対象の値は書き換えられますが、バリデーション失敗後のリダイレクトで old() が返す値には影響しません。

request()->merge() はリクエストインスタンス自体の入力値を書き換えます。FormRequest はインスタンス化時にリクエストインスタンスから入力値をコピーして自身のデータバッグを持つため、その後で request()->merge() を呼んでもバリデーション対象の値には影響しません。一方でバリデーション失敗時にリダイレクトバックする際は、withInput() を使用してリクエストインスタンスの値をセッションにフラッシュするため、request()->merge() で上書きすると old() の値も変わります。二つの違いをまとめると以下となります。

$this->merge() request()->merge()
スコープ FormRequest 内部のみ リクエストインスタンス全体
バリデーション対象の値 上書きされる 変化しない
old() に渡される値 変化しない 上書きされる

old_string()とFormRequest案の比較

2つの対策はどちらも有効ですが、適用範囲と副作用が異なります。それらを考慮した上でどちらの対策を講じるのか方針を決めるのが良いでしょう。

old_string() FormRequest正規化
影響範囲 Blade のみ(表示層で完結) リクエストインスタンス全体
メリット 変更が限定的で既存の処理に影響しにくい。導入コストが低い 書き換え処理が prepareForValidation() に集約され管理しやすい。セッションにフラッシュされる値もクリーン
デメリット old() から old_string() への置き換え漏れがあると再発する。セッションには不正な値がそのまま残る request() は多くの箇所で共有されるため副作用に注意が必要。

まとめ

今回は配列インジェクションによる攻撃と、その対策としてold_string()FormRequestでの正規化という2つの方法を紹介しました。配列インジェクションはバリデーションでガードされている場合、クリティカルなエラーには直結しません。しかし放置すると不要なエラーログが蓄積しストレージを圧迫するだけでなく、本来対応すべきエラーへの気づきが遅れるリスクがあります。“転ばぬ先の杖"というように、小さな問題ですが対策を講じておくのが大切だと思います。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。