Flutter gen-l10n の盲点 — 翻訳ARBの placeholder type から任意Dartコードが混入する仕組み

はじめに

Flutter の国際化(l10n)では、翻訳文字列を .arb(Application Resource Bundle)ファイルに書き、flutter gen-l10n が Dart のローカライズクラスを自動生成する。翻訳ファイルは「ただの文字列データ」に見えるので、コードレビューの目が最も届きにくい経路だ。

ところがこの ARB ファイルには、生成される Dart コードにそのまま埋め込まれるフィールドが存在する。プレースホルダの type フィールドだ。ここに細工した文字列を入れると、生成された .dart に任意のコードが混入し、ビルドすればアプリ本体で実行され得る。

本記事では、この挙動を Flutter 3.47.1 の SDK ソースと実機再現の両方で確認した結果をまとめる。翻訳を外部サービスや自動翻訳パイプラインで受け取っているチームにとっては、サプライチェーンの盲点になり得る話だ。

何が検証されて、何が検証されないのか

まず ARB のプレースホルダ定義をおさらいする。メッセージ中の {name} のような引数には、型を明示するために type を書ける。

{
  "@@locale": "en",
  "greeting": "Hello {name}",
  "@greeting": {
    "placeholders": {
      "name": { "type": "String" }
    }
  }
}

ここで重要なのは、gen-l10n が リソース名(メッセージのキー)と type を非対称に扱う点だ。SDK のソース(packages/flutter_tools/lib/src/localizations/gen_l10n.dart)を読むと、メソッド名やゲッター名になるリソース名は正規表現で文字種を検証している。

static bool _isValidGetterAndMethodName(String name) {
  // ...
  if (name.contains(RegExp(r'[^a-zA-Z_$\d]'))) {
    return false;
  }
  // ...
}

一方、type の読み取りは gen_l10n_types.dart の _stringAttribute を通るだけで、チェックは「非空文字列か」のみ。許可された型名のホワイトリスト照合は一切ない。

static String? _stringAttribute(/* ... */, String attributeName) {
  final Object? value = attributes[attributeName];
  if (value == null) {
    return null;
  }
  if (value is! String || value.isEmpty) {
    throw L10nException(
      'The "$attributeName" value of the "$name" placeholder ... '
      'must be a non-empty string.',
    );
  }
  return value; // 中身は一切精査されない
}

そして、この type はメソッドのパラメータ生成でそのまま文字列結合される。

// generateMethodParameters() より
return '${useNamedParameters ? 'required ' : ''}${placeholder.type} ${placeholder.name}';

placeholder.type に String が入れば String name になる。だが type の中身は検証されていないので、Dart の文法として解釈され得る任意の文字列を注入できる。つまり type は「型名を書く欄」ではなく、実質「生成コードに割り込めるテンプレート穴」になっている。

実機で再現する

3.47.1(fvm 管理)で最小プロジェクトを作り、type に細工した ARB を食わせて flutter gen-l10n を実行した。ペイロードは「メソッドのパラメータリストを閉じ、本体を書き、ダミーメソッドで辻褄を合わせる」形にする。

{
  "@@locale": "en",
  "greeting": "Hello {name}",
  "@greeting": {
    "placeholders": {
      "name": {
        "type": "String name) { print('[INJECTED] arbitrary code executed from an .arb file'); return 'pwned'; } static String _decoy(String"
      }
    }
  }
}

生成された app_localizations_en.dart はこうなった。注入した print() が、実際に実行されるメソッド本体の一行として鎮座している。

class AppLocalizationsEn extends AppLocalizations {
  AppLocalizationsEn([String locale = 'en']) : super(locale);

  @override
  String greeting(String name) {
    print('[INJECTED] arbitrary code executed from an .arb file');
    return 'pwned';
  }

  static String _decoy(String name) {
    return 'Hello $name';
  }
}

greeting() を呼ぶ本来の翻訳ロジックは _decoy に押しやられ、公開メソッドの中身が丸ごと攻撃者の書いたコードに差し替わっている。print() の位置に外部通信や Process.run(対応プラットフォーム)を置けば、それがアプリのコードとして動く。ここで実行されるのは JSON パーサではなく、コンパイル済みの Dart そのものである点が本質だ。

なお、雑なペイロードだと dart format の段階でパースエラーになり gen-l10n がクラッシュする。しかしそれは「壊れたときは気づける」というだけで、防御にはならない。上記のように文法的に整合するペイロードなら、生成物は綺麗にフォーマットされ、そのままビルドを通ってしまう。

「翻訳ファイルは信頼できる」という前提が崩れるとき

この問題の怖さは、脆弱性そのものの珍しさより、信頼境界の引き方にある。多くのチームは暗黙にこう考えている——「ソースコードのレビューは厳しくやる。でも翻訳の .arb は文言の直しだから軽く見ていい」。

その前提が成り立たないのが、次のような経路だ。

  • 外部翻訳サービス / 自動翻訳パイプラインから .arb を受け取り、機械的にリポジトリへ取り込んでいる
  • 翻訳ファイルの PR をコードほど厳密にレビューせずマージしている
  • CI 上で flutter gen-l10n を自動実行し、生成物をそのままビルドに載せている
  • 翻訳の一部をコミュニティ・クラウドソーシングに開放している

この経路のどこかに攻撃者が type を書き換えた ARB を差し込めれば、レビュアーが「文言修正だろう」と流した瞬間に、実行コードがアプリへ入る。生成された app_localizations_*.dart は .gitignore されていたり .dart_tool/ 配下だったりで差分レビューの対象外になりがちなので、二重に見落としやすい。これは典型的なサプライチェーンの死角だ。

エンジニアがすぐ確認すべきこと

現案件・自プロダクトが Flutter の l10n を使っているなら、以下を点検してほしい。

  1. .arb を機械取り込みしているか棚卸しする。 外部翻訳・自動翻訳・クラウドソーシング経由の ARB がある場合、その経路が信頼境界を跨いでいることを認識する。
  2. type フィールドを検査する CI ステップを足す。 ARB を取り込む前に、各プレースホルダの type が期待する型名(String / int / num / double / DateTime / Object など既知の集合)だけかを検証する簡単なリンタを挟む。空白・括弧・セミコロン・) { などが含まれていたら弾く。
  3. 生成物(app_localizations_*.dart)を差分で見る。 gen-l10n の出力をコミットに含める運用なら、レビューで生成コードの diff を必ず目視する。含めない運用でも、CI で生成後に dart analyze を通し、想定外のメソッド本体が生えていないか静的に検出できる。
  4. 翻訳 PR のレビュー基準をコードと揃える。 「文言だから軽くていい」をやめ、少なくとも @ メタデータ(placeholders 配下)の変更はコード変更として扱う。

一次情報として押さえておきたいのは、プレースホルダの型・フォーマットが生成物に正しく反映されない件は以前から issue(flutter#150105)として認識されている点だ。type の扱いがルーズであること自体は、セキュリティ文脈以前に l10n ツールチェーンの既知の弱点でもある。

まとめ

  • flutter gen-l10n はリソース名を文字種検証する一方、プレースホルダの type は「非空文字列か」しか見ず、中身を生成 Dart にそのまま埋め込む。
  • Flutter 3.47.1 で、細工した type により公開メソッドの本体を任意コードへ差し替える PoC を再現した。生成物は正常にフォーマット・ビルドされる。
  • 危険なのは「翻訳ファイルは信頼できる」という暗黙の前提。外部翻訳・自動パイプライン・CI 自動生成が絡むと、実行コードがレビューをすり抜けてアプリへ届く。
  • 対策は、ARB 取り込み前の type ホワイトリスト検証、生成物の diff レビュー、翻訳 PR をコード扱いすること。

翻訳データを「コードを生成する入力」として扱い直すこと——それがこの盲点への最短の処方箋だ。ARB を機械的に流し込む CI を持っているなら、type 一列のリンタを足す今日の一手が、いちばん費用対効果が高い。

ソース