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 を使っているなら、以下を点検してほしい。
.arbを機械取り込みしているか棚卸しする。 外部翻訳・自動翻訳・クラウドソーシング経由の ARB がある場合、その経路が信頼境界を跨いでいることを認識する。typeフィールドを検査する CI ステップを足す。 ARB を取り込む前に、各プレースホルダのtypeが期待する型名(String/int/num/double/DateTime/Objectなど既知の集合)だけかを検証する簡単なリンタを挟む。空白・括弧・セミコロン・){などが含まれていたら弾く。- 生成物(
app_localizations_*.dart)を差分で見る。gen-l10nの出力をコミットに含める運用なら、レビューで生成コードの diff を必ず目視する。含めない運用でも、CI で生成後にdart analyzeを通し、想定外のメソッド本体が生えていないか静的に検出できる。 - 翻訳 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 一列のリンタを足す今日の一手が、いちばん費用対効果が高い。
ソース
- Code injection via .arb translation files in flutter gen-l10n — r/FlutterDev
- [gen-l10n] Arb file placeholders types & format not taken into account for generated files · Issue #150105
- Internationalizing Flutter apps | Flutter Docs
- 本記事のコード引用・PoC は Flutter 3.47.1 の SDK ソース(
packages/flutter_tools/lib/src/localizations/)と、同バージョンでの実機再現に基づく。