Intent redirection sur Android : attaques et correctifs
Comment l'intent redirection permet d'atteindre des composants Android non exportés, de divulguer des données via setResult() et de détourner les PendingIntent, et six moyens de s'en protéger.
L'intent redirection est une classe de vulnérabilité des applications Android dans laquelle un acteur malveillant incite une application victime à transférer ou à envoyer un Intent en son nom. Comme l'intent transféré s'exécute sous l'identité et avec les permissions de l'application victime, l'attaquant peut atteindre des composants non exportés, voler des données sensibles ou élever ses privilèges — le tout sans détenir la moindre permission particulière.
Cette vulnérabilité figure systématiquement parmi les résultats les plus impactants des programmes de bug bounty Android et a touché des applications très exposées, dont TikTok et plusieurs applications internes de Google.
Comment fonctionne l'intent redirection
Au fond, l'attaque exploite un modèle proxy : une application victime reçoit un intent provenant d'une source externe (contrôlée par l'attaquant) et, faute de validation adéquate, utilise une partie de cet intent pour démarrer une autre activity, envoyer un broadcast ou se lier à un service.
Déroulement de l'attaque

Le schéma d'abus de base
Le code vulnérable le plus simple ressemble à ceci :
// VULNERABLE — Unvalidated intent forwarding
Intent forward = getIntent().getParcelableExtra("next_intent");
startActivity(forward);
La victime fait aveuglément confiance à l'extra next_intent et lance le composant indiqué par l'attaquant — y compris les activities non exportées de la victime elle-même.
Schémas de code vulnérables
1. Transfert d'intent non validé
// 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. Fuite de données via 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. Exploitation via deep link
// 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)
}
}
Scénarios d'attaque
Scénario 1 — Accéder à des composants non exportés
Un attaquant construit un intent ciblant l'InternalSettingsActivity non exportée de la victime :
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);
Scénario 2 — Vol de données via content provider
L'attaquant exploite les octrois de permission sur URI pour lire le content provider privé de la victime :
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
Scénario 3 — Détournement d'authentification / de session
Si l'application victime possède une activity exportée qui transfère un résultat issu d'un flux de connexion interne, l'attaquant peut intercepter le jeton d'authentification renvoyé via setResult().
Scénario 4 — Enchaînement de deep links
Un attaquant enchaîne plusieurs deep links à travers différentes applications, utilisant chacune d'elles comme tremplin pour finalement atteindre un composant protégé dans une cible à forte valeur.
Détournement de PendingIntent
Alors que l'intent redirection classique exploite une application qui transfère un Intent fourni par l'attaquant sous sa propre identité, le détournement de PendingIntent inverse l'attaque : l'application victime est amenée à remettre un PendingIntent que l'attaquant peut transformer en arme. Comme un PendingIntent exécute l'Intent qu'il encapsule avec l'UID et les permissions de son créateur, un attaquant qui obtient un PendingIntent mutable ou vide emprunte de fait l'identité de l'application victime.
Comprendre le modèle de sécurité des PendingIntent
Un PendingIntent est un jeton de capacité (capability token), pas un objet de données. L'Intent, les flags et l'UID du créateur résident dans system_server ; l'application ne détient qu'un handle binder.
- Créateur : appelle
PendingIntent.getActivity()→system_serverenregistre l'entrée et renvoie un handle. - Destinataire : reçoit le handle via IPC (extra d'Intent, notification, etc.).
- Envoi : lorsque
.send()est appelé,system_serverrecherche l'entrée et déclenche l'Intent encapsulé avec l'UID du créateur, pas celui de l'émetteur.

Cette conception rend le PendingIntent sûr à transmettre par défaut — c'est d'ailleurs pourquoi il constitue la mesure d'atténuation recommandée contre l'intent redirection classique. La vulnérabilité n'apparaît que lorsque le créateur traite ce jeton de capacité comme une simple donnée inoffensive.
Le schéma vulnérable
Le détournement de PendingIntent survient lorsqu'une application victime :
- Crée un PendingIntent avec
FLAG_MUTABLE(ou omet les flags sur les cibles antérieures à Android 12, où mutable était la valeur par défaut), ET - Encapsule un Intent implicite (sans composant explicite défini), ET
- Expose le PendingIntent à un attaquant — en le plaçant dans un broadcast, une action de notification, la réponse d'un service exporté, ou un Intent renvoyé à un appelant.
Un attaquant qui reçoit le PendingIntent mutable peut appeler .send(Context, int, Intent fillInIntent) avec un fillInIntent fournissant le composant, l'action, les données ou les extras manquants. Le système fusionne les champs du fillIn dans l'Intent d'origine selon les règles définies dans Intent.fillIn(), puis envoie le résultat avec l'UID du créateur.
Les conséquences pratiques comprennent :
- Lancer les activities, services ou providers non exportés de la victime.
- Lire ou écrire les URI
content://privées de la victime en accordantFLAG_GRANT_READ_URI_PERMISSIONvia le fillIn. - Envoyer des broadcasts en tant que victime vers des receivers qui font confiance à la signature ou au nom de package de la victime.
- Déclencher des flux internes privilégiés (gestion de compte, modifications de paramètres, callbacks d'achat intégré).
Un exemple concret
// 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
Le receiver de l'attaquant obtient le PendingIntent et l'exploite :
// 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
Même si AdminActivity n'est pas exportée, le lancement réussit car le système l'exécute sous l'UID de VictimApp.
En quoi cela diffère de l'intent redirection classique
| Aspect | Intent redirection classique | Détournement de PendingIntent |
|---|---|---|
| Ce que fournit l'attaquant | Un Intent brut à la victime |
Rien — l'attaquant reçoit un jeton de la part de la victime |
| Rôle de la victime | Extrait un Intent et l'envoie | Remet un PendingIntent encapsulant un Intent implicite et mutable |
| Identité sous laquelle l'Intent s'exécute | La victime (confused deputy) | La victime (délégation de capacité) |
| Mesure d'atténuation principale | Valider/filtrer l'Intent transféré ; utiliser un PendingIntent à la place | Utiliser FLAG_IMMUTABLE ; utiliser des Intent explicites |
Les dégâts sont les mêmes — le code s'exécute en tant que victime — mais la forme de l'attaque est inversée. L'intent redirection classique nécessite un chemin d'entrée par lequel la victime accepte des données de l'attaquant. Le détournement de PendingIntent nécessite un chemin de sortie par lequel la victime divulgue une capacité.
Stratégies d'atténuation
1. Valider avec resolveActivity()
Avant de transférer un intent, vérifiez qu'il résout vers un composant sûr et attendu :
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. Liste blanche de composants
Maintenez un ensemble explicite de composants cibles autorisés :
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. Utiliser IntentSanitizer (AndroidX)
L'API Jetpack IntentSanitizer propose une API déclarative, de type builder, pour supprimer les champs dangereux :
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. Restreindre les composants exportés
Passez en revue votre AndroidManifest.xml et assurez-vous que les composants ne sont exportés que lorsque c'est réellement nécessaire :
<!-- 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 immuables
Utilisez toujours FLAG_IMMUTABLE, sauf si le PendingIntent doit réellement être mutable :
val pi = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
6. Supprimer les flags d'Intent dangereux
Avant de transférer un intent, retirez les flags susceptibles d'accorder des permissions sur 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
}
Idées reçues
| Idée reçue | Réalité |
|---|---|
« Mettre android:exported=false rend mon composant sûr. » |
Si une activity proxy exportée lui transfère des intents, le composant reste effectivement atteignable. |
| « Je valide l'action de l'intent, donc je suis protégé. » | L'attaquant contrôle tous les champs — composant, URI de données, extras, flags. Ne valider que l'action est insuffisant. |
| « Les contrôles de permission sur le composant cible bloqueront l'attaquant. » | L'intent transféré s'exécute sous l'identité de l'application victime, qui détient déjà les permissions requises. |
« Seul startActivity() est vulnérable. » |
sendBroadcast(), startService(), bindService() et startActivityForResult() sont tout aussi exposés. |
| « Les deep links sont sûrs car ils ne font qu'ouvrir des URL web. » | Le schéma intent:// permet de construire des intents arbitraires à partir d'une URI, contournant les hypothèses habituelles sur les URL. |
Impact dans le monde réel
- TikTok (2022) : des chercheurs ont démontré qu'une chaîne d'intent redirections pouvait permettre la prise de contrôle de comptes en atteignant des activities non exportées gérant les jetons d'authentification. Cette découverte a donné lieu à une récompense de bug bounty significative.
- Bulletins de sécurité Google : plusieurs Android Security Bulletins ont traité des failles d'intent redirection dans des composants système, rappelant que même le code first-party n'est pas à l'abri.
- Tendances en bug bounty : l'intent redirection figure systématiquement parmi les principales catégories de vulnérabilités Android sur des plateformes comme HackerOne et Bugcrowd, avec des récompenses à la hauteur de son impact élevé.
Conclusion : la défense en profondeur
Aucun correctif unique n'élimine le risque d'intent redirection. Une approche en couches est indispensable :
- Minimiser les exports — n'exporter que les composants qui nécessitent réellement un accès externe.
- Valider tous les intents transférés — utiliser
resolveActivity(), des listes blanches de composants ouIntentSanitizer. - Supprimer les flags dangereux — retirer les flags d'octroi d'URI avant tout transfert.
- Utiliser des PendingIntent immuables — privilégier
FLAG_IMMUTABLEpar défaut. - Protéger les résultats sensibles — ne jamais renvoyer de données internes via
setResult()à des appelants non vérifiés. - Intégrer l'analyse statique — des outils comme Android Lint et Semgrep peuvent détecter les mauvaises configurations en CI/CD avant la mise en production.
En traitant tout intent reçu de l'extérieur comme une entrée non fiable et en appliquant ces contrôles de façon systématique, les développeurs Android peuvent neutraliser efficacement l'intent redirection comme vecteur d'attaque.