
Writeup et exploit pour CVE-2025-22441 : élévation de privilèges d'une application installée vers le processus SystemUI sur Android en raison du passage d'un ApplicationInfo non fiable à LoadedApk
Le correctif pour ce problème est apparu sous la référence CVE-2025-22441 : bulletin correctif suivi
ApplicationInfoApplicationInfo est une structure définissant diverses informations sur une application installée, notamment le chemin vers le fichier APK à partir duquel les ressources et le code sont chargés
Habituellement, elle est transmise du système aux applications, mais il existe parfois des cas où un appelant non-système peut fournir la sienne. Par exemple, dans le passé, il y a eu une vulnérabilité dans la méthode bindBackupAgent(), où un attaquant pouvait passer en paramètre son propre objet ApplicationInfo avec des valeurs uid et sourceDir qui n'étaient pas vérifiées par rapport aux applications réellement installées sur le système, car cette méthode était destinée à être appelée en interne par system_server, mais était exposée à adb shell
Cette fois-ci, cependant, j'ai examiné de près le champ ApplicationInfo dans RemoteViews
RemoteViews est un objet décrivant une vue pouvant provenir d'un autre processus. Il est notamment utilisé pour les widgets de l'écran d'accueil, où l'application fournissant le widget construit un RemoteViews qui est ensuite "appliqué" dans le processus de l'écran d'accueil
Les autres endroits où RemoteViews sont utilisés sont les notifications (appliquées par le processus SystemUI) et les boîtes de dialogue d'autofill (fournies par le service d'autofill, appliquées par system_server)
Le champ RemoteViews.mApplication est sérialisé via Parcel et peut donc provenir de processus distants, et à chaque fois que RemoteViews sont appliqués, il est utilisé par la méthode suivante (extrait de code source) :```java
private Context getContextForResourcesEnsuringCorrectCachedApkPaths(Context context) {
if (mApplication != null) {
if (context.getUserId() == UserHandle.getUserId(mApplication.uid)
&& context.getPackageName().equals(mApplication.packageName)) {
return context;
}
try {
LoadedApk.checkAndUpdateApkPaths(mApplication);
return context.createApplicationContext(mApplication,
Context.CONTEXT_RESTRICTED);
} catch (NameNotFoundException e) {
Log.e(LOG_TAG, "Package name " + mApplication.packageName + " not found");
}
}
return context;
}
La partie la plus intéressante ici est l’appel à `LoadedApk.checkAndUpdateApkPaths()`, car il s’agit d’une méthode statique qui va modifier un certain état global [(source de l’extrait)](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=2275-2302;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1)```java
public static void checkAndUpdateApkPaths(ApplicationInfo expectedAppInfo) {
// Get the LoadedApk from the cache
ActivityThread activityThread = ActivityThread.currentActivityThread();
if (activityThread == null) {
Log.e(TAG, "Cannot find activity thread");
return;
}
checkAndUpdateApkPaths(activityThread, expectedAppInfo, /* cacheWithCode */ true);
checkAndUpdateApkPaths(activityThread, expectedAppInfo, /* cacheWithCode */ false);
}
private static void checkAndUpdateApkPaths(ActivityThread activityThread,
ApplicationInfo expectedAppInfo, boolean cacheWithCode) {
String expectedCodePath = expectedAppInfo.getCodePath();
LoadedApk loadedApk = activityThread.peekPackageInfo(
expectedAppInfo.packageName, /* includeCode= */ cacheWithCode);
// If there is load apk cached, or if the cache is valid, don't do anything.
if (loadedApk == null || loadedApk.getApplicationInfo() == null
|| loadedApk.getApplicationInfo().getCodePath().equals(expectedCodePath)) {
return;
}
// Duplicate framework logic
List<String> oldPaths = new ArrayList<>();
LoadedApk.makePaths(activityThread, expectedAppInfo, oldPaths);
// Force update the LoadedApk instance, which should update the reference in the cache
loadedApk.updateApplicationInfo(expectedAppInfo, oldPaths);
}
Discutons de ce qui se passe dans ces méthodes
Tout d'abord, nous appelons checkAndUpdateApkPaths avec 3 paramètres, cacheWithCode étant défini à la fois sur true et false. L'objet LoadedApk peut être construit selon deux modes, soit avec mIncludeCode défini sur true ou false, ce qui, du point de vue du SDK, correspond à un Context avec le drapeau CONTEXT_INCLUDE_CODE défini ou non
Cette méthode utilisera d'abord ActivityThread.peekPackageInfo(), qui renverra l'instance LoadedApk déjà mise en cache. Par conséquent, si l'application n'a pas précédemment construit de LoadedApk avec un packageName et un includeCode correspondants, peekPackageInfo() renverra null et checkAndUpdateApkPaths() ne fera rien
En revenant sur RemoteViews.getContextForResourcesEnsuringCorrectCachedApkPaths(), nous avons context.createApplicationContext(mApplication, Context.CONTEXT_RESTRICTED) là, le drapeau CONTEXT_INCLUDE_CODE n'a pas été spécifié (il en va de même pour les contextes passés en argument à cette méthode) et donc cette méthode utilisera toujours un LoadedApk avec mIncludeCode=false
Par conséquent, pour RemoteViews, la mise à jour de la version avec le code est inutile, car les RemoteViews utilisent toujours un Context sans code. Le message du commit indique simplement qu'il existe deux endroits où ApplicationInfo peut être mis en cache et je pense que supprimer la mise à jour de la version avec le code ne poserait pas de problème ici (car checkAndUpdateApkPaths() n'est utilisé que par AppWidgetHostView et RemoteViews). L'argument contraire serait toutefois d'éviter la possibilité de désynchroniser divers caches, et même si le fait de supprimer l'appel avec cacheWithCode=true élimine l'impact le plus grave de ce bug, cela ne corrige pas complètement le problème, car la modification des seules ressources (au lieu du code) pourrait toujours présenter un intérêt pour un attaquant
LoadedApk.updateApplicationInfo()Jusqu'à présent, le code présenté n'est utilisé que pour les RemoteViews (widgets, notifications, etc.), mais nous allons maintenant aborder LoadedApk.updateApplicationInfo(), qui est également utilisé pour mettre à jour les processus d'application en cours d'exécution après l'installation d'un nouveau split (par exemple lors de l'utilisation de la livraison à la demande de Play Feature Delivery)
Maintenant, l'objet ApplicationInfo non fiable provenant de RemoteViews sera transmis à updateApplicationInfo(), examinons ce que fait cette méthode (source de l'extrait)```java
public void updateApplicationInfo(@NonNull ApplicationInfo aInfo,
@Nullable List oldPaths) {
if (!setApplicationInfo(aInfo)) {
return;
}
Examinons cette méthode `setApplicationInfo()` [(source de l'extrait)](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=392-422;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1)```java
private boolean setApplicationInfo(ApplicationInfo aInfo) {
if (mApplicationInfo != null && mApplicationInfo.createTimestamp > aInfo.createTimestamp) {
Slog.w(TAG, "New application info for package " + aInfo.packageName
+ " is out of date with TS " + aInfo.createTimestamp + " < the current TS "
+ mApplicationInfo.createTimestamp);
return false;
}
// Snip: assign fields such as mAppDir and mResDir on this object from aInfo
return true;
}
Le champ createTimestamp est normalement défini sur SystemClock.uptimeMillis(), mais comme cet objet provient de l'attaquant, cela signifie que l'attaquant peut fournir une valeur future pour empêcher l'exécution d'appels ultérieurs à updateApplicationInfo()
Revenons à updateApplicationInfo() (source de l'extrait)```java
final List newPaths = new ArrayList<>();
makePaths(mActivityThread, aInfo, newPaths);
final List addedPaths = new ArrayList<>(newPaths.size());
// Snip: populate addedPaths with items that are in newPaths and not in oldPaths (passed in argument) synchronized (mLock) { createOrUpdateClassLoaderLocked(addedPaths);
La liste de `addedPaths` est construite : en cas d'installation d'un nouveau split, `oldPaths` contiendrait la liste des chemins utilisés avant l'installation du nouveau split et `addedPaths` contiendrait la liste des fichiers `.apk` qui doivent être ajoutés au `ClassLoader` existant. Cependant, dans le cas de `checkAndUpdateApkPaths()`, `oldPaths` sera construit à partir du même objet `ApplicationInfo` et donc `addedPaths` sera vide et l'attaquant ne pourra pas ajouter de nouveaux chemins au `ClassLoader` existant ici.
`createOrUpdateClassLoaderLocked()` est une méthode longue, mais seules quelques choses sont intéressantes ici :
* [Si `mIncludeCode` est `false`, la seule chose faite est de créer un `ClassLoader` sans référencer aucun fichier `apk`/`dex`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=968-990;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1)
* La chose la plus dangereuse est de créer un nouveau `ClassLoader` en utilisant les chemins qui viennent d'être définis avec un `ApplicationInfo` contrôlé par l'attaquant, mais cela ne se produira que si [`mDefaultClassLoader` est `null`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=1005;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1), ce qui signifie que c'est le premier `createOrUpdateClassLoaderLocked()` sur cette instance de `LoadedApk` (le champ `mDefaultClassLoader` fait référence à l'instance de `ClassLoader` utilisée avant l'application de [`AppComponentFactory.instantiateClassLoader()`](https://developer.android.com/reference/android/app/AppComponentFactory#instantiateClassLoader(java.lang.ClassLoader,%20android.content.pm.ApplicationInfo)))
* Il y a aussi [l'ajout de nouveaux chemins au chemin de recherche de bibliothèques natives de ce `ClassLoader`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=1050-1058;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1), cependant pour exploiter cela, l'application victime devrait effectuer un `System.loadLibrary()` qui n'est normalement pas présent
* Et il y a [l'ajout des chemins `apk`/`dex` de l'argument `addedPaths` au `ClassLoader` existant](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=1060-1065;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1), cependant sur le chemin de code de `RemoteViews`, `addedPaths` sera vide
En revenant à `updateApplicationInfo()` encore une fois, il reste une chose pertinente que cette méthode fait : [remplacer `LoadedApk.mResources` par une instance qui utilise la nouvelle valeur de `mResDir`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=382;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1). Il convient de noter qu'il s'agit d'une nouvelle instance, et non d'une mise à jour des instances déjà présentes. Les `Context` nouvellement créés renverront/ utiliseront cette nouvelle instance de `Resources`, mais tous les `Context` déjà créés continueront d'utiliser l'ancienne `Resources`.
# Résumé de l'impact
Pour résumer les sections ci-dessus, chaque fois que le processus victime applique un `RemoteViews`, cette vulnérabilité permet de :
* Remplacer les [`Resources` (chaînes de localisation, mises en page, etc.)](https://developer.android.com/guide/topics/resources/providing-resources) des Activités nouvellement créées dans ce processus
* Ajouter au chemin de recherche de bibliothèques natives utilisé par `System.loadLibrary()`, cependant exploiter cela nécessiterait que la victime appelle `System.loadLibrary()` en passant le nom d'une bibliothèque normalement absente, ce qui est peu probable
* Charger du code Java arbitraire si le processus victime a utilisé `createPackageContext(CONTEXT_INCLUDE_CODE)`, mais n'a pas appelé [`getClassLoader()` sur ce `Context`](https://developer.android.com/reference/android/content/Context#getClassLoader()), cependant comme le chargement de code est la raison d'utiliser le drapeau `CONTEXT_INCLUDE_CODE`, cela est également peu susceptible de se produire naturellement
Maintenant, bien que la chance de charger du code Java dans ce cas soit peu susceptible de se produire naturellement, j'ai pu la déclencher.
# Chargement du `WebView`
Sur les versions modernes d'Android, `WebView` ne fait pas partie du système, mais est chargé depuis un `apk` normal défini dans la [configuration système](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/res/res/xml/config_webview_packages.xml) et [est soit une application système, soit possède une signature correspondant à celle définie dans le système](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/core/java/com/android/server/webkit/WebViewUpdateServiceImpl2.java;l=688-705;drc=f8f9e9ab11e65322aaaeb7373efd77cf1d671928)
Le chargement de cet `apk` est effectué en [créant un nouveau `Context`, en passant les drapeaux `Context.CONTEXT_INCLUDE_CODE | Context.CONTEXT_IGNORE_SECURITY`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/webkit/WebViewFactory.java;l=521;drc=f8f9e9ab11e65322aaaeb7373efd77cf1d671928)
Regardons maintenant l'appelant de cette méthode [(source de l'extrait)](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/webkit/WebViewFactory.java;l=541-563;drc=f8f9e9ab11e65322aaaeb7373efd77cf1d671928)```java
// Overall snip: try/catch/finally, Trace, logging and timing measurement
webViewContext = getWebViewContextAndSetProvider();
if (android.content.res.Flags.registerResourcePaths()) {
Resources.registerResourcePaths(webViewContext.getPackageName(),
webViewContext.getApplicationInfo());
} else {
// Snip: old resource update path, won't be used on latest Android builds
}
ClassLoader clazzLoader = webViewContext.getClassLoader();
getWebViewContextAndSetProvider() est une méthode qui crée Context, Resources.registerResourcePaths() est notre seule chance d'introduire un délai pour que nous puissions appeler checkAndUpdateApkPaths() dans un autre thread et charger du code Java arbitraire, car webViewContext.getClassLoader() est la fin de la fenêtre où il existe un LoadedApk avec mIncludeCode étant true et mDefaultClassLoader étant null
Resources.registerResourcePaths() atteindra appendLibAssetsLocked(), qui itérera sur le champ mResourceImpls du singleton, qui contient toutes les ressources chargées dans ce processus et peut donc contenir des ressources construites à l'aide d'ApplicationInfo implanté via RemoteViews
Mon idée initiale était de placer dans ApplicationInfo un grand nombre de chemins d'overlays ayant tous le même hashCode(), ce qui serait lent à dédupliquer par l'appel createNewResourceKeyIfNeeded() et bien qu'en utilisant l'interpréteur (par exemple lors de l'utilisation d'un débogueur ou dans une application fraîchement installée) ce délai était significatif, lorsque le runtime avait effectué des optimisations, le délai introduit par les collisions de hash n'était pas significatif. Cependant, une autre raison de ralentissement est apparue : puisque ces chemins d'overlays ne pointaient pas vers des fichiers existants, pour chaque overlay qui échouait à se charger, un message de journal avec trace de pile était imprimé et cela conduisait en pratique à un ralentissement, rendant l'exploitation de cette condition de course réalisable
Les applications peuvent transmettre des RemoteViews à SystemUI dans les Notifications. Mon application d'exploitation demande la permission POST_NOTIFICATIONS, ce qui me semble être une interaction utilisateur raisonnable, bien qu'il soit possible que certaines notifications contournent cette exigence
Normalement, SystemUI n'utilise pas WebView et ne peut en fait même pas le faire, car SystemUI utilise un stockage protégé par l'appareil (c'est-à-dire un stockage qui n'est pas protégé par les identifiants de l'écran de verrouillage) et l'implémentation de WebView l'interdit
Cette exploitation me permet cependant de modifier Resources, donc j'ai d'abord modifié la mise en page utilisée par SlicePermissionActivity pour inclure un élément <WebView />, puis j'ai lancé cette Activity, ce qui déclenche l'initialisation de WebView sur le thread principal de SystemUI
Ensuite, je dois faire appeler RemoteViews.getContextForResourcesEnsuringCorrectCachedApkPaths() sur un autre thread afin de remplacer le chemin utilisé pour charger le code
Normalement, la publication d'une notification implique plusieurs threads dans le processus SystemUI, y compris le thread principal (donc pendant que WebView est initialisé, je ne peux pas publier une autre notification). Cependant, l'application de RemoteViews se produit sur un thread séparé et je peux suspendre cette opération en ayant un ImageView dans RemoteViews et en lui demandant de charger une image depuis mon ContentProvider
Cette exploitation charge WebView en remplaçant la mise en page utilisée dans SlicePermissionActivity. Si la mise à jour du code n'était pas possible, attaquer SlicePermissionActivity pourrait toujours être précieux, car un attaquant pourrait remplacer la mise en page pour masquer entièrement le message d'origine et par exemple afficher un journal des modifications, remplacer le bouton d'autorisation par « Compris » et le bouton de refus par une chaîne vide, le rendant ainsi effectivement invisible. Bien que le framework Slices soit déprécié, il existe encore des Slices de paramètres qui peuvent par exemple modifier le paramètre de données mobiles sans interaction de l'utilisateur
Une autre invite de permission importante dans SystemUI est la confirmation de Media Projection. Cependant, celle-ci s'est avérée non vulnérable car elle utilise le contexte de l'application pour Dialog
Les fragments de Préférences sont également intéressants, car vous pouvez définir un Intent à lancer par Preference. Cependant, dans le cas de SystemUI, les seules Activities de préférences sont celles liées à SystemUI Tuner et au Mode Démo, mais celles-ci s'exécutent dans un processus différent de celui qui affiche les Notifications