
Writeup e exploit para CVE-2025-22441: Escalação de privilégio de aplicativo instalado para o processo SystemUI no Android devido à passagem de ApplicationInfo não confiável para LoadedApk
A correção para este problema apareceu como CVE-2025-22441: boletim patch acompanhamento
ApplicationInfo por aíApplicationInfo é uma estrutura que define várias informações sobre o aplicativo instalado, principalmente o caminho para o arquivo apk do qual recursos e código são carregados
Normalmente ele é passado do sistema para os aplicativos, no entanto, às vezes há casos em que um chamador não pertencente ao sistema pode fornecer um próprio. Por exemplo, no passado houve vulnerabilidade no método bindBackupAgent(), onde o atacante podia passar como parâmetro um objeto ApplicationInfo próprio com valores de uid e sourceDir que não eram verificados em relação aos aplicativos realmente instalados no sistema, porque esse método deveria ser chamado internamente pelo system_server, mas estava exposto ao adb shell
Desta vez, porém, examinei de perto o campo ApplicationInfo dentro de RemoteViews
RemoteViews é um objeto que descreve uma visualização que pode vir de outro processo. Isso é usado principalmente para widgets da tela inicial, onde o aplicativo que fornece o widget constrói RemoteViews e então ele é "aplicado" dentro do processo da tela inicial
Outros lugares onde RemoteViews são usados incluem notificações (aplicadas pelo processo SystemUI) e diálogos de preenchimento automático (fornecidos pelo serviço de preenchimento automático, aplicados pelo system_server)
O campo RemoteViews.mApplication é serializado através de Parcel e, portanto, pode vir de processos remotos e, sempre que RemoteViews são aplicados, ele é usado pelo seguinte método (fonte do trecho):```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;
}
O mais interessante aqui é a chamada `LoadedApk.checkAndUpdateApkPaths()`, pois este é um método estático e modificará algum estado global [(fonte do trecho)](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);
}
Vamos discutir o que está acontecendo nesses métodos
Primeiro, estamos chamando checkAndUpdateApkPaths com 3 parâmetros, com cacheWithCode definido tanto como true quanto como false. O objeto LoadedApk pode ser construído em dois modos, ou com mIncludeCode sendo true ou false, o que, em termos de SDK, corresponde a um Context com a flag CONTEXT_INCLUDE_CODE definida ou não
Esse método primeiro usará ActivityThread.peekPackageInfo(), que retornará a instância de LoadedApk já armazenada em cache; portanto, se o aplicativo não tiver construído anteriormente um LoadedApk com packageName e includeCode correspondentes, peekPackageInfo() retornará null e checkAndUpdateApkPaths() não fará nada
Voltando a RemoteViews.getContextForResourcesEnsuringCorrectCachedApkPaths(), temos context.createApplicationContext(mApplication, Context.CONTEXT_RESTRICTED) ali; a flag CONTEXT_INCLUDE_CODE não foi especificada (o mesmo ocorre com os contextos passados como argumento para esse método) e, portanto, esse método sempre usará LoadedApk com mIncludeCode=false
Portanto, para RemoteViews, atualizar a versão com código é desnecessário, pois RemoteViews sempre usam Context sem código. A mensagem do commit apenas diz que há dois lugares onde ApplicationInfo pode ser armazenado em cache e acredito que remover a atualização da versão com código não causaria problemas aqui (já que checkAndUpdateApkPaths() é usado apenas por AppWidgetHostView e RemoteViews). O contra-argumento para isso, no entanto, é evitar a possibilidade de dessincronizar vários caches e, embora remover a chamada com cacheWithCode=true elimine o impacto mais severo desse bug, isso não corrige completamente o problema, pois a modificação apenas de recursos (em vez de código) ainda pode ser valiosa para um atacante
LoadedApk.updateApplicationInfo()Até agora, o código apresentado é usado apenas para RemoteViews (widgets, notificações, etc.), mas agora entraremos em LoadedApk.updateApplicationInfo(), que também é usado para atualizar processos de aplicativos em execução após a instalação de um novo split (por exemplo, ao usar a entrega sob demanda do Play Feature Delivery)
Agora, o objeto ApplicationInfo não confiável de RemoteViews será passado para updateApplicationInfo(); vamos dar uma olhada no que esse método faz (fonte do trecho)```java
public void updateApplicationInfo(@NonNull ApplicationInfo aInfo,
@Nullable List oldPaths) {
if (!setApplicationInfo(aInfo)) {
return;
}
Vamos dar uma olhada nesse `setApplicationInfo()` [(fonte do trecho)](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;
}
O campo createTimestamp normalmente é definido como SystemClock.uptimeMillis(), mas como este objeto vem do atacante, isso significa que o atacante pode fornecer um valor futuro para impedir que chamadas posteriores de updateApplicationInfo() sejam executadas
Voltando a updateApplicationInfo() (fonte do trecho)```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);
A lista de `addedPaths` é construída: no caso de instalação de um novo split, `oldPaths` conteria a lista de caminhos que eram usados antes da instalação do novo split e `addedPaths` conteria a lista de arquivos `.apk` que precisam ser adicionados ao `ClassLoader` existente, porém, no caso de `checkAndUpdateApkPaths()`, `oldPaths` será construído a partir exatamente do mesmo objeto `ApplicationInfo` e, portanto, `addedPaths` estará vazio e o atacante não conseguirá adicionar novos caminhos ao `ClassLoader` existente aqui
`createOrUpdateClassLoaderLocked()` é um método longo, mas apenas algumas coisas são interessantes aqui:
* [Se `mIncludeCode` for `false`, a única coisa feita é criar um `ClassLoader` sem referenciar nenhum arquivo `apk`/`dex`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=968-990;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1)
* A coisa mais perigosa é criar um novo `ClassLoader` usando caminhos que acabaram de ser definidos usando um `ApplicationInfo` controlado pelo atacante, porém isso só acontecerá se [`mDefaultClassLoader` for `null`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=1005;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1), o que significa que esta é a primeira chamada a `createOrUpdateClassLoaderLocked()` nessa instância de `LoadedApk` (o campo `mDefaultClassLoader` se refere à instância de `ClassLoader` usada antes de aplicar [`AppComponentFactory.instantiateClassLoader()`](https://developer.android.com/reference/android/app/AppComponentFactory#instantiateClassLoader(java.lang.ClassLoader,%20android.content.pm.ApplicationInfo)))
* Há também [adicionar novos caminhos ao caminho de busca de bibliotecas nativas desse `ClassLoader`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=1050-1058;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1), porém, para explorar isso, o aplicativo vítima precisaria executar `System.loadLibrary()`, o que normalmente não está presente
* E há [adicionar caminhos `apk`/`dex` do argumento `addedPaths` ao `ClassLoader` existente](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=1060-1065;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1), porém, no caminho de código de `RemoteViews`, `addedPaths` estará vazio
Voltando a `updateApplicationInfo()` novamente, ainda há uma coisa relevante que este método faz: [substituir `LoadedApk.mResources` por uma instância que usa o novo valor de `mResDir`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=382;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1). Deve-se notar que esta é uma nova instância, não uma atualização das instâncias já presentes. `Context`s recém-criados retornarão/usarão esta nova instância de `Resources`, porém qualquer `Context` já criado continuará usando o `Resources` antigo
# Resumo do impacto
Para resumir as seções acima, sempre que o processo vítima aplica um `RemoteViews`, esta vulnerabilidade permite:
* Substituir [`Resources` (strings de localização, layouts, etc.)](https://developer.android.com/guide/topics/resources/providing-resources) de Activities recém-criadas dentro desse processo
* Acrescentar ao caminho de busca de bibliotecas nativas usado por `System.loadLibrary()`, porém explorar isso exigiria que a vítima chamasse `System.loadLibrary()` passando o nome de uma biblioteca que normalmente está ausente, o que é improvável
* Carregar código Java arbitrário se o processo vítima tiver usado `createPackageContext(CONTEXT_INCLUDE_CODE)`, mas não tiver chamado [`getClassLoader()` nesse `Context`](https://developer.android.com/reference/android/content/Context#getClassLoader()), porém, como carregar código é o motivo para usar a flag `CONTEXT_INCLUDE_CODE`, isso também é improvável de acontecer naturalmente
Agora, embora a chance de carregar código Java neste caso seja improvável de acontecer naturalmente, fui capaz de acioná-la
# Carregando o `WebView`
Em versões modernas do Android, o `WebView` não faz parte do sistema, mas é carregado de um `apk` normal que é definido na [configuração do sistema](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/res/res/xml/config_webview_packages.xml) e [é um aplicativo do sistema ou tem uma assinatura correspondente à definida dentro do sistema](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)
O carregamento desse `apk` é feito [criando um novo `Context`, passando as flags `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)
Vamos agora dar uma olhada no chamador desse método [(fonte do trecho)](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() é um método que cria Context, Resources.registerResourcePaths() é a nossa única chance de introduzir atraso para que possamos chamar checkAndUpdateApkPaths() em outra thread e carregar código Java arbitrário, pois webViewContext.getClassLoader() é o fim da janela onde existe LoadedApk com mIncludeCode sendo true e mDefaultClassLoader sendo null
Resources.registerResourcePaths() chegará a appendLibAssetsLocked(), que itera sobre o campo mResourceImpls do singleton, que contém todos os recursos que foram carregados neste processo e, portanto, pode conter recursos construídos usando ApplicationInfo plantado através de RemoteViews
Minha ideia inicial era colocar em ApplicationInfo um grande número de caminhos de overlays, todos com o mesmo hashCode(), o que seria lento para deduplicar pela chamada createNewResourceKeyIfNeeded() e, embora ao usar interpretador (por exemplo, ao usar depurador ou dentro de um app recém-instalado) isso fosse um atraso significativo, quando o runtime havia feito otimizações, o atraso introduzido por colisões de hash não era significativo; no entanto, outra razão de lentidão apareceu: como esses caminhos de overlays não apontavam para arquivos existentes, para cada overlay que falhava ao carregar, uma mensagem de log com stack trace era impressa e isso, na prática, levava à lentidão, tornando viável a exploração dessa condição de corrida
Apps podem passar RemoteViews para o SystemUI em Notificações. Meu app de exploração solicita a permissão POST_NOTIFICATIONS, que considero uma interação razoável do usuário, embora possa ser possível que algumas notificações contornem esse requisito
Normalmente o SystemUI não usa WebView e, na verdade, nem pode fazê-lo, pois o SystemUI usa armazenamento protegido por dispositivo (ou seja, armazenamento que não é protegido por credencial de tela de bloqueio) e a implementação de WebView não permite isso
Esta exploração me permite modificar Resources, então primeiro modifiquei o layout usado por SlicePermissionActivity para incluir o elemento <WebView />, depois iniciei essa Activity, o que aciona a inicialização do WebView na thread principal do SystemUI
Então preciso fazer com que RemoteViews.getContextForResourcesEnsuringCorrectCachedApkPaths() seja chamado em outra thread para substituir o caminho usado para carregar o código
Normalmente, postar uma notificação envolve múltiplas threads no processo do SystemUI, incluindo a thread principal (então, enquanto o WebView está sendo inicializado, não posso postar outra notificação); no entanto, a aplicação de RemoteViews acontece em uma thread separada e posso suspender essa operação tendo um ImageView dentro do RemoteViews e pedindo que ele carregue uma imagem do meu ContentProvider
Esta exploração carrega WebView substituindo o layout usado em SlicePermissionActivity. Se atualizar o código não fosse possível, atacar SlicePermissionActivity ainda poderia ser valioso, pois o atacante poderia substituir o layout para ocultar completamente a mensagem original e, por exemplo, mostrar um changelog, substituir o botão de permitir por "Entendi" e o botão de negar por uma string vazia, tornando-o efetivamente invisível. Embora o framework Slices esteja obsoleto, ainda existem Slices de Configurações que podem, por exemplo, alterar a configuração de dados móveis sem interação do usuário
Outro prompt de permissão importante dentro do SystemUI é a confirmação de Media Projection; no entanto, esse não se mostrou vulnerável, pois usa o contexto da aplicação para o Dialog
Também são interessantes os fragments de Preferências, pois você pode definir um Intent a ser lançado por Preference; no entanto, no caso do SystemUI, as únicas Activities de preferência são as relacionadas ao SystemUI Tuner e ao Modo Demo, mas essas rodam em processo diferente daquele que mostra as Notificações