当社のAIエンジンNeutronが、UCバークレーのCyberGymベンチマークで96.75%のスコアを記録しました。 詳細を見る

セキュリティ

セキュリティ

Androidのインテントリダイレクション:攻撃手法と修正方法

インテントリダイレクションによって攻撃者が非エクスポートのAndroidコンポーネントに到達し、setResult()経由でデータを漏えいさせ、PendingIntentを悪用する仕組みと、それを防ぐ6つの方法を解説します。

インテントリダイレクションは、悪意のある攻撃者が被害者アプリをだまし、自分に代わってIntentを転送またはディスパッチさせるAndroidアプリケーションの脆弱性クラスです。転送されたインテントは被害者アプリのアイデンティティと権限で実行されるため、攻撃者は自身では特別な権限を一切持たずに、エクスポートされていないコンポーネントに到達したり、機密データを盗んだり、権限昇格を行ったりできます。

この脆弱性は、Androidのバグバウンティプログラムにおいて常に最も影響の大きい検出結果の一つに位置付けられており、TikTokや複数のGoogle純正アプリなど、注目度の高いアプリケーションにも影響を与えてきました。


インテントリダイレクションの仕組み

この攻撃の核心は、プロキシパターンの悪用にあります。被害者アプリケーションは外部(攻撃者が制御する)ソースからインテントを受け取り、十分な検証を行わないまま、そのインテントの一部を使って別のアクティビティの起動、ブロードキャストの送信、サービスのバインドを行います。

攻撃の流れ

Androidのインテントリダイレクションでは、攻撃者がエクスポートされたプロキシコンポーネントを悪用して信頼できないIntentを転送させ、非エクスポートのターゲットに到達し、setResult()経由でデータを漏えいさせ、URI付与によってコンテンツを盗み、認証フローを乗っ取ることができます。あわせて、実践的な多層防御の緩和策も示しています。

中核となる誤用パターン

最も単純な脆弱なコードは次のようになります。

// VULNERABLE — Unvalidated intent forwarding
Intent forward = getIntent().getParcelableExtra("next_intent");
startActivity(forward);

被害者アプリはnext_intentエクストラを無条件に信頼し、攻撃者が指定したコンポーネントであれば何でも起動します。被害者自身のエクスポートされていないアクティビティも例外ではありません。


脆弱なコードパターン

1. 検証なしのインテント転送

// VULNERABLE
class RouterActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val target = intent.getParcelableExtra<Intent>("target")
        target?.let { startActivity(it) }   // No validation at all
        finish()
    }
}

2. setResult()によるデータ漏えい

// VULNERABLE — Returns internal data to the caller
public class LeakyActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Intent next = getIntent().getParcelableExtra("next");
        startActivityForResult(next, 1001);
    }

    @Override
    protected void onActivityResult(int req, int res, Intent data) {
        super.onActivityResult(req, res, data);
        // Forwards internal data straight back to the (attacker) caller
        setResult(res, data);
        finish();
    }
}

3. ディープリンクの悪用

// VULNERABLE — Deep link handler forwards without checking scheme/host
class DeepLinkActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val uri = intent.data
        val redirect = Intent(Intent.ACTION_VIEW, uri)
        // Attacker can craft: myapp://redirect?url=intent://...
        startActivity(redirect)
    }
}

攻撃シナリオ

シナリオ1:エクスポートされていないコンポーネントへのアクセス

攻撃者は、被害者のエクスポートされていないInternalSettingsActivityを標的とするインテントを作成します。

Intent inner = new Intent();
inner.setComponent(new ComponentName(
    "com.victim.app",
    "com.victim.app.InternalSettingsActivity"
));

Intent outer = new Intent();
outer.setComponent(new ComponentName(
    "com.victim.app",
    "com.victim.app.RouterActivity"  // exported proxy
));
outer.putExtra("target", inner);
startActivity(outer);

シナリオ2:コンテンツプロバイダーからのデータ窃取

攻撃者はURI権限の付与を利用して、被害者のプライベートなコンテンツプロバイダーを読み取ります。

val inner = Intent().apply {
    data = Uri.parse("content://com.victim.app.provider/private_data")
    flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}

val outer = Intent().apply {
    component = ComponentName("com.victim.app", "com.victim.app.ProxyActivity")
    putExtra("next_intent", inner)
}
startActivityForResult(outer, 0)
// onActivityResult receives the private data

シナリオ3:認証/セッションハイジャック

被害者アプリに、内部のログインフローの結果を転送するエクスポートされたアクティビティがある場合、攻撃者はsetResult()経由で返される認証トークンを傍受できます。

シナリオ4:ディープリンクの連鎖

攻撃者は複数のアプリにまたがるディープリンクを連鎖させ、各アプリを踏み台にして、最終的に価値の高い標的の保護されたコンポーネントに到達します。

PendingIntentリダイレクション

従来のインテントリダイレクションは、攻撃者が渡したIntentを自らのアイデンティティで転送するアプリを悪用しますが、PendingIntentリダイレクションは攻撃の向きが逆になります。被害者アプリがだまされて、攻撃者が武器化できるPendingIntentを渡してしまうのです。PendingIntentはラップしたIntentを作成者のUIDと権限で実行するため、ミュータブルまたは空のPendingIntentを入手した攻撃者は、事実上、被害者アプリのアイデンティティを借用できます。

PendingIntentのセキュリティモデルを理解する

PendingIntentはデータオブジェクトではなく、ケイパビリティトークンです。Intent、フラグ、作成者のUIDはsystem_serverの内部に保持され、アプリが持つのはbinderハンドルだけです。

  • 作成者:PendingIntent.getActivity()を呼び出す → system_serverがレコードを保存し、ハンドルを返す
  • 受信者:IPC(Intentのエクストラ、通知など)経由でハンドルを受け取る
  • ディスパッチ:.send()が呼び出されると、system_serverがレコードを検索し、ラップされたIntentを送信者のUIDではなく作成者のUIDで実行する

作成者のUIDとIntentがsystem_serverの外に出ることはなく、受信者が持つのは不透明なbinderハンドルだけ

この設計により、PendingIntentはデフォルトで安全に受け渡しできるものになっています。これが、従来のインテントリダイレクションに対する推奨の緩和策とされている理由です。脆弱性が生じるのは、作成者がこのケイパビリティトークンを無害なデータとして扱った場合に限られます。

脆弱なパターン

PendingIntentリダイレクションは、被害者アプリが次のすべてに該当する場合に発生します。

  1. FLAG_MUTABLE付きでPendingIntentを作成する(またはミュータブルがデフォルトだったAndroid 12より前をターゲットとする場合にフラグを省略する)、かつ
  2. 暗黙的なIntentをラップする(明示的なコンポーネントが設定されていない)、かつ
  3. PendingIntentを攻撃者に露出させる(ブロードキャスト、通知アクション、エクスポートされたサービスのレスポンス、呼び出し元に返されるIntentに含めるなど)

ミュータブルなPendingIntentを受け取った攻撃者は、欠けているコンポーネント、アクション、データ、エクストラを補うfillInIntentを指定して.send(Context, int, Intent fillInIntent)を呼び出せます。システムはIntent.fillIn()のルールに従ってfillInのフィールドを元のIntentにマージし、その結果を作成者のUIDでディスパッチします。

実際の影響には次のようなものがあります。

  • 被害者のエクスポートされていないアクティビティ、サービス、プロバイダーの起動
  • fillInを通じてFLAG_GRANT_READ_URI_PERMISSIONを付与することによる、被害者のプライベートなcontent:// URIの読み書き
  • 被害者の署名やパッケージ名を信頼するレシーバーに対する、被害者になりすましたブロードキャストの送信
  • 特権的な内部フロー(アカウント管理、設定変更、アプリ内課金のコールバック)の起動

具体例

// Vulnerable code inside VictimApp
Intent intent = new Intent();                         // implicit — no component
PendingIntent pi = PendingIntent.getActivity(
    this, 0, intent,
    PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);

Intent deliver = new Intent("com.victim.HAND_OUT_TOKEN");
deliver.putExtra("token", pi);
sendBroadcast(deliver);                               // attacker receives pi

攻撃者のレシーバーはPendingIntentを入手し、それを悪用します。

// Inside AttackerApp
PendingIntent pi = intent.getParcelableExtra("token", PendingIntent.class);

Intent fillIn = new Intent();
fillIn.setClassName("com.victim", "com.victim.internal.AdminActivity");
fillIn.putExtra("cmd", "wipe");

pi.send(context, 0, fillIn);      // launches VictimApp's internal activity as VictimApp

AdminActivityはエクスポートされていないにもかかわらず、システムがVictimAppのUIDで起動を実行するため、起動は成功します。

従来のインテントリダイレクションとの違い

観点 従来のインテントリダイレクション PendingIntentリダイレクション
攻撃者が渡すもの 被害者への生のIntent なし(攻撃者は被害者からトークンを受け取る)
被害者の役割 Intentを取り出してディスパッチする 暗黙的かつミュータブルなIntentをラップしたPendingIntentを渡す
Intentが実行されるアイデンティティ 被害者(混乱した代理人) 被害者(ケイパビリティの委譲)
主な緩和策 転送するIntentを検証/フィルタリングする。代わりにPendingIntentを使う FLAG_IMMUTABLEを使う。明示的なIntentを使う

被害は同じで、コードが被害者として実行されます。しかし、攻撃の形は逆転しています。従来のリダイレクションには、被害者が攻撃者のデータを受け入れる入力経路が必要です。PendingIntentリダイレクションには、被害者がケイパビリティを漏えいさせる出力経路が必要です。

緩和策

1. resolveActivity()で検証する

インテントを転送する前に、それが安全で想定どおりのコンポーネントに解決されることを確認します。

val forwarded = intent.getParcelableExtra<Intent>("next")
forwarded?.let {
    val resolved = it.resolveActivity(packageManager)
    if (resolved != null && resolved.packageName == packageName) {
        // GOOD — only allow intents targeting our own package
        startActivity(it)
    }
}

2. コンポーネントの許可リスト

許可するターゲットコンポーネントを明示的なセットとして管理します。

private val ALLOWED_TARGETS = setOf(
    "com.myapp.HomeActivity",
    "com.myapp.SettingsActivity"
)

fun safeForward(intent: Intent) {
    val target = intent.getParcelableExtra<Intent>("target") ?: return
    val comp = target.component?.className
    if (comp in ALLOWED_TARGETS) {
        startActivity(target)
    }
}

3. IntentSanitizer(AndroidX)を使う

JetpackのIntentSanitizer APIは、危険なフィールドを取り除くための宣言的なビルダー形式のAPIを提供します。

val sanitizer = IntentSanitizer.Builder()
    .allowComponent(ComponentName(this, HomeActivity::class.java))
    .allowAction(Intent.ACTION_VIEW)
    .allowDataWithAuthority("myapp.example.com")
    .allowExtra("safe_key", String::class.java)
    .build()

val clean = sanitizer.sanitizeByFiltering(untrustedIntent)
startActivity(clean)

4. エクスポートするコンポーネントを制限する

AndroidManifest.xmlを見直し、本当に必要な場合にのみコンポーネントをエクスポートするようにします。

<!-- GOOD — not exported; cannot be reached externally -->
<activity
    android:name=".InternalSettingsActivity"
    android:exported="false" />

<!-- If exported is required, protect with a permission -->
<activity
    android:name=".RouterActivity"
    android:exported="true"
    android:permission="com.myapp.permission.INTERNAL" />

5. イミュータブルなPendingIntent

PendingIntentが本当にミュータブルである必要がない限り、常にFLAG_IMMUTABLEを使用します。

val pi = PendingIntent.getActivity(
    context, 0, intent,
    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)

6. 危険なインテントフラグを除去する

転送する前に、URI権限を付与する可能性のあるフラグを削除します。

fun stripDangerousFlags(intent: Intent): Intent {
    intent.removeFlags(
        Intent.FLAG_GRANT_READ_URI_PERMISSION or
        Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
        Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
        Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
    )
    return intent
}

よくある誤解

誤解 実際
「android:exported=falseを設定すれば、コンポーネントは安全になる」 エクスポートされたプロキシアクティビティがそのコンポーネントにインテントを転送している場合、コンポーネントは事実上到達可能です。
「インテントのアクションを検証しているので守られている」 攻撃者はコンポーネント、データURI、エクストラ、フラグといったすべてのフィールドを制御できます。アクションだけを検証しても不十分です。
「ターゲットコンポーネントの権限チェックが攻撃者をブロックする」 転送されたインテントは被害者アプリのアイデンティティで実行され、被害者アプリは必要な権限をすでに保持しています。
「脆弱なのはstartActivity()だけだ」 sendBroadcast()、startService()、bindService()、startActivityForResult()もすべて影響を受けます。
「ディープリンクはWebのURLを開くだけなので安全だ」 intent://スキームを使うとURIから任意のインテントを構築でき、一般的なURLに関する前提を回避できます。

現実世界への影響

  • TikTok(2022年):研究者は、インテントリダイレクションを連鎖させることで、認証トークンを扱うエクスポートされていないアクティビティに到達し、ユーザーアカウントを乗っ取れることを実証しました。この検出結果には多額のバグバウンティ報奨金が支払われました。
  • Google Security Bulletins:複数のAndroidセキュリティ情報(Android Security Bulletins)で、システムレベルのコンポーネントにおけるインテントリダイレクションの欠陥が修正されており、純正コードでさえ無縁ではないことを示しています。
  • バグバウンティの傾向:インテントリダイレクションは、HackerOneやBugcrowdなどのプラットフォームにおいて、Androidの脆弱性カテゴリの上位に常に登場しており、報奨金の額もその影響の大きさを反映しています。

まとめ:多層防御

インテントリダイレクションのリスクを単独で排除できる修正はありません。多層的なアプローチが不可欠です。

  1. エクスポートを最小限にする:外部からのアクセスが本当に必要なコンポーネントだけをエクスポートする
  2. 転送するすべてのインテントを検証する:resolveActivity()、コンポーネントの許可リスト、IntentSanitizerを使う
  3. 危険なフラグを除去する:転送する前にURI付与フラグを削除する
  4. イミュータブルなPendingIntentを使う:FLAG_IMMUTABLEをデフォルトにする
  5. 機密性の高い結果を保護する:検証されていない呼び出し元にsetResult()経由で内部データを返さない
  6. 静的解析を組み込む:Android LintやSemgrepなどのツールで、リリース前にCI/CDで設定ミスを検出できる

外部から受け取るすべてのインテントを信頼できない入力として扱い、これらの制御を一貫して適用することで、Android開発者はインテントリダイレクションという攻撃ベクトルを効果的に無力化できます。