
CVE-2026-49881 — логическая ошибка в классе InCallController в службе Telecom в Android 17, которая позволяет непривилегированному приложению получить произвольное выполнение кода с правами UID 1000 system_server.
Это PoC и разбор CVE-2026-49881 — логической ошибки в классе InCallController в Telecom-сервисе Android 17, которая позволяет непривилегированному приложению получить выполнение произвольного кода с UID 1000 system_server без дополнительного взаимодействия с пользователем. Мы также показываем, что выполнение кода в system_server по-прежнему можно легко адаптировать для получения устойчивости (persistence), даже в современных версиях Android.
Я сообщил об этой уязвимости команде Android Security 2026-04-10, она была подтверждена 2026-05-06 и исправлена в сентябрьском бюллетене безопасности Android за 2026 год. (см. здесь патч)
На момент сообщения, насколько мне известно, она активно затрагивала только сборки Pixel начиная с Android 16 QPR3 (а также бета-сборки 17), но позже она попала и в стабильный релиз AOSP Android 17.
Чтобы защититься от этой проблемы, обязательно установите обновление системы Google Play, а также системный патч безопасности. (Telecom является компонентом mainline начиная с Android 17)
system_server с помощью уязвимости, записывает id и стек вызовов в logcat, а затем переустанавливает себя как компонент system_server.build.sh. В противном случае вы можете вручную выполнить ./gradlew assembleSystemRelease, переместить полученный app-system-release.apk в app/src/poc/assets/system.apk, а затем выполнить ./gradlew assemblePocRelease.system_server также отключает Play Protect, устанавливая package_verifier_user_consent в -1 в Settings.Global, поскольку тот иногда может перехватывать транзакцию переустановки из-за неизвестных подписей. После тестирования вам следует снова включить эту опцию в настройках.system_server может потребовать индивидуальной настройки для каждого OEM, поскольку он опирается на обход структуры PMS с использованием символов, которые OEM иногда меняют.Ожидаемый вывод logcat для 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...
Это необычно прямолинейная уязвимость. Всякий раз, когда происходят определённые действия, связанные с Telecom, InCallController пытается обнаружить доступные сервисы через getInCallServiceComponents. Это естественным образом срабатывает при регистрации вызова в системе, но приложение может вызвать это и по требованию благодаря транзакционному API вызовов TelecomManager.addCall. (примечание: именно это использует кнопка Start Exploit в PoC) Этот API требует MANAGE_OWN_CALLS, но это обычное и незаметное для пользователя разрешение, выдаваемое автоматически при установке.
Это перечисление реализовано в уязвимых версиях так:
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;
}
...
}
}
}
Обратите внимание, что serviceClassExists выполняется для любого компонента, который объявляет intent InCallService вместе со значением метаданных android.telecom.CLASS_EXISTENCE_CHECK, а не только для валидных/включённых InCallService. (эта проверка реализована ниже по ветке с помощью getInCallServiceType и isServiceEnabled)
serviceClassExists реализован так:
/**
* 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;
}
}
Опасности createPackageContext с CONTEXT_IGNORE_SECURITY хорошо документированы, и в данном случае сам system_server выполняет его против недоверенного компонента лишь для того, чтобы проверить, существует ли класс в DEX этого приложения. На первый взгляд, однако, это может выглядеть безопасно, поскольку полученный контекст используется в Class.forName с initialize=false — разработчик, скорее всего, знал об этом риске и указал этот аргумент, чтобы гарантировать, что чужой класс не будет инициализирован в system_server.
К сожалению, эта осторожность проявляется слишком поздно — ущерб уже наносится вызовом getClassLoader на чужом контексте. Если приложение атакующего определяет AppComponentFactory в своём манифесте как android:appComponentFactory, метод getClassLoader на LoadedApk, соответствующем контексту, созданному с CONTEXT_INCLUDE_CODE и CONTEXT_IGNORE_SECURITY, сначала извлекает загрузчик классов по умолчанию чужого приложения с диска, а затем выполняет и конструктор фабрики, и её метод instantiateClassLoader, прежде чем вернуть загрузчик классов.
Поскольку этот класс контролируется приложением атакующего, это немедленно приводит к выполнению произвольного кода в контексте Telecom.
Чтобы показать последствия успешного выполнения кода в system_server, PoC демонстрирует переустановку себя в качестве постоянного компонента system_server. (техника адаптирована из метода, опубликованного в PoC AbxOverflow / CVE-2024-34740 Михалом Беднарским)
Мы делаем это следующим образом:
Signature нашего PoC-приложения, динамически рефлектируя в PackageManagerService.mSettings напрямую, оказавшись в system_server.SharedUserSetting, соответствующий "android.uid.system", и внедряем Signature PoC дважды в его getSigningDetails().mPastSigningCertificates с CertCapabilities.SHARED_USER_ID.android:sharedUserId="android.uid.system" и android:process="system". (это также сбрасывает изменчивую модификацию из предыдущего шага в packages.xml)Это заставляет canJoinSharedUserId() в PackageSignatures пройти проверку благодаря совпадению истории ротации и даёт постоянные привилегии system_server.
Один вопрос, который у вас может возникнуть: почему эта уязвимость вообще существует — в частности, почему существует опциональная проверка существования класса, скрытая за недокументированным флагом метаданных?
Невозможно сказать наверняка, но вероятный ответ на эту загадку можно найти, копнув глубже в AOSP: и проверка класса, и сравнение метаданных, вероятно, были добавлены для учёта android.net.ConnectivityCallListenerService, который реализовывался примерно в то же время.
Этот сервис изначально был определён в манифесте фреймворка, но нигде не был реализован. (а когда он был добавлен, то за флагом функции Flags.FLAG_ENABLE_INCALL_SERVICE_API) После того как в тестировании наблюдались краши, кто-то, вероятно, решил реализовать исправление как переиспользуемую проверку «защиты в глубину», а не как жёстко прописанное исключение — определение этого сервиса в манифесте говорит, что он определяет "android.telecom.CLASS_EXISTENCE_CHECK", чтобы «указать, что класс этого сервиса может отсутствовать во всех сборках» и «указать Telecom проверить существование класса перед попыткой привязки». (и именно поэтому мы сейчас здесь)