
# 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 في 2026-04-10، وتم تأكيدها في 2026-05-06، وتم إصلاحها في نشرة أمان Android لشهر سبتمبر 2026. (انظر هنا للتصحيح)
في وقت الإبلاغ، كانت تؤثر فقط على إصدارات Pixel التي تعمل بنظام Android 16 QPR3 وما بعده (بالإضافة إلى إصدارات 17 Beta) على حد علمي، لكنها انتقلت لاحقًا إلى الإصدار المستقر من AOSP لنظام Android 17.
لحماية نفسك من هذه المشكلة، تأكد من تثبيت تحديث نظام Google Play بالإضافة إلى تصحيح أمان النظام. (Telecom هو مكوّن رئيسي (mainline) منذ Android 17)
system_server باستخدام الثغرة، ويسجل id وتتبع المكدس (stack trace) إلى 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 بعض التخصيص حسب الشركة المصنعة لأنها تعتمد على اجتياز بنية PMS باستخدام رموز قد تغيرها الشركات المصنعة أحيانًا.مخرجات logcat المتوقعة لإثبات المفهوم هي:
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. يحدث هذا بشكل طبيعي عند تسجيل مكالمة مع النظام، ولكن يمكن للتطبيق تشغيله عند الطلب أيضًا بفضل واجهة برمجة تطبيقات المعاملات TelecomManager.addCall. (ملاحظة: هذا ما يستخدمه زر "بدء الاستغلال" في إثبات المفهوم) تتطلب هذه الواجهة إذن 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 ضد أي مكوّن يعلن عن نية 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 في بيانه (manifest) كـ android:appComponentFactory، فإن طريقة getClassLoader على LoadedApk المطابق لسياق تم إنشاؤه باستخدام CONTEXT_INCLUDE_CODE و CONTEXT_IGNORE_SECURITY تسترجع أولاً محمّل الفئات الافتراضي للتطبيق الأجنبي من القرص ثم تشغّل كلًا من مُنشئ المصنع وطريقته instantiateClassLoader قبل إرجاع محمّل الفئات.
نظرًا لأن هذه الفئة يمكن التحكم فيها بواسطة تطبيق المهاجم، يؤدي هذا فورًا إلى تنفيذ تعليمات برمجية عشوائي في سياق Telecom.
لإظهار عواقب تنفيذ التعليمات البرمجية الناجح في system_server، يوضح إثبات المفهوم إعادة تثبيت نفسه كمكوّن ثابت (persistent) في system_server. (التقنية مقتبسة من التقنية المنشورة في إثبات مفهوم AbxOverflow / CVE-2024-34740 بواسطة Michał Bednarski)
نقوم بذلك عن طريق:
Signature الخاص بتطبيق إثبات المفهوم عن طريق الانعكاس الديناميكي (dynamic reflection) في PackageManagerService.mSettings مباشرة بمجرد أن نكون داخل system_server.SharedUserSetting المطابق لـ "android.uid.system" وحقن Signature الخاص بإثبات المفهوم مرتين في getSigningDetails().mPastSigningCertificates مع CertCapabilities.SHARED_USER_ID.android:sharedUserId="android.uid.system" و android:process="system" إلى البيان (manifest). (والذي يقوم أيضًا بمسح التعديل المتطاير من الخطوة السابقة إلى packages.xml)هذا يجعل canJoinSharedUserId() في PackageSignatures ينجح بسبب تطابق سجل التدوير (rotation history)، ويؤدي إلى صلاحيات system_server ثابتة.
سؤال قد يتبادر إلى ذهنك هو لماذا توجد هذه الثغرة أصلًا - على وجه الخصوص، لماذا يوجد فحص اختياري لوجود الفئة محميًا خلف علامة بيانات وصفية غير موثقة؟
من المستحيل الجزم بذلك، لكن الإجابة المحتملة على هذا اللغز يمكن العثور عليها بالتعمق قليلاً في AOSP: من المحتمل أن كلاً من فحص الفئة ومقارنة البيانات الوصفية أُضيفا لمراعاة android.net.ConnectivityCallListenerService، الذي كان قيد التنفيذ في نفس الوقت تقريبًا.
تم تعريف هذه الخدمة في البداية في بيان إطار العمل (framework manifest) ولكن لم يتم تنفيذها في أي مكان. (وعندما تمت إضافتها، كانت خلف علامة الميزة Flags.FLAG_ENABLE_INCALL_SERVICE_API) بمجرد ملاحظة الأعطال أثناء الاختبار، من المحتمل أن شخصًا ما قرر تنفيذ الإصلاح كفحص قابل لإعادة الاستخدام "للدفاع في العمق" بدلاً من استثناء مبرمج بشكل ثابت - تعريف هذه الخدمة في البيان يقول أنها تعرّف "android.telecom.CLASS_EXISTENCE_CHECK" "للإشارة إلى أن فئة هذه الخدمة قد لا تكون موجودة في جميع الإصدارات" و"لتوجيه Telecom للتحقق من وجود الفئة قبل محاولة الربط". (ولهذا السبب نحن هنا الآن)