
CVE-2025-22441에 대한 분석 및 익스플로잇: Android에서 신뢰할 수 없는 ApplicationInfo가 LoadedApk로 전달되어 설치된 앱에서 SystemUI 프로세스로 권한 상승이 발생함
이 문제에 대한 수정 사항이 CVE-2025-22441로 공개되었습니다: 공지 패치 후속 조치
ApplicationInfo 전달하기ApplicationInfo는 설치된 앱에 대한 다양한 정보를 정의하는 구조체로, 가장 주목할 만한 것은 리소스와 코드가 로드되는 apk 파일의 경로입니다.
일반적으로 시스템에서 애플리케이션으로 전달되지만, 비시스템 호출자가 자체 객체를 제공할 수 있는 경우도 있습니다. 예를 들어 과거에는 bindBackupAgent() 메서드의 취약점이 있었는데, 공격자가 매개변수로 자체 ApplicationInfo 객체를 uid 및 sourceDir 값과 함께 전달할 수 있었고, 이 값들이 시스템에 실제로 설치된 앱과 대조하여 검증되지 않았습니다. 해당 메서드는 system_server에 의해 내부적으로 호출되도록 설계되었지만 adb shell에 노출되어 있었기 때문입니다.
이번에는 RemoteViews 내의 ApplicationInfo 필드를 자세히 살펴보았습니다.
RemoteViews는 다른 프로세스에서 올 수 있는 뷰를 설명하는 객체입니다. 가장 대표적으로 홈 화면 위젯에 사용되며, 위젯을 제공하는 앱이 RemoteViews를 빌드한 다음 홈 화면 프로세스 내에서 "적용"됩니다.
RemoteViews가 사용되는 다른 곳으로는 알림(SystemUI 프로세스에 의해 적용됨)과 자동 완성 대화상자(자동 완성 서비스가 제공하고 system_server에 의해 적용됨)가 있습니다.
RemoteViews.mApplication 필드는 Parcel을 통해 직렬화되므로 원격 프로세스에서 올 수 있으며, RemoteViews가 적용될 때마다 다음 메서드에서 사용됩니다 (소스 스니펫):```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;
}
여기서 가장 흥미로운 부분은 `LoadedApk.checkAndUpdateApkPaths()` 호출입니다. 이는 정적 메서드이며 일부 전역 상태를 수정할 것입니다 [(코드 스니펫 소스)](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);
}
이 메서드들에서 무슨 일이 일어나고 있는지 논의해 보겠습니다.
먼저 cacheWithCode를 true와 false 모두로 설정하면서 3-파라미터 checkAndUpdateApkPaths를 호출하고 있습니다. LoadedApk 객체는 두 가지 모드로 생성될 수 있는데, mIncludeCode가 true 또는 false인 경우이며, SDK 관점에서 이는 CONTEXT_INCLUDE_CODE 플래그가 설정되었는지 여부에 따라 Context에 매핑됩니다.
해당 메서드는 먼저 ActivityThread.peekPackageInfo()를 사용하는데, 이는 이미 캐시된 LoadedApk 인스턴스를 반환합니다. 따라서 앱이 이전에 일치하는 packageName과 includeCode로 LoadedApk를 생성하지 않았다면 peekPackageInfo()는 null을 반환하고 checkAndUpdateApkPaths()는 아무 작업도 수행하지 않습니다.
다시 RemoteViews.getContextForResourcesEnsuringCorrectCachedApkPaths()를 살펴보면, 거기에 context.createApplicationContext(mApplication, Context.CONTEXT_RESTRICTED)가 있습니다. CONTEXT_INCLUDE_CODE 플래그는 지정되지 않았으며(해당 메서드에 인수로 전달된 컨텍스트도 마찬가지), 따라서 해당 메서드는 항상 mIncludeCode=false인 LoadedApk를 사용하게 됩니다.
따라서 RemoteViews는 항상 코드가 없는 Context를 사용하므로, RemoteViews에 대해 코드와 함께 버전을 업데이트하는 것은 불필요합니다. 커밋 메시지에는 ApplicationInfo가 캐시될 수 있는 두 곳이 있다고만 언급되어 있으며, 코드와 함께 버전을 업데이트하는 것을 제거해도 여기서는 문제가 발생하지 않을 것이라고 생각합니다(checkAndUpdateApkPaths()는 AppWidgetHostView와 RemoteViews에서만 사용되기 때문입니다). 그러나 이에 대한 반론은 다양한 캐시가 서로 동기화되지 않을 가능성을 피하는 것이며, cacheWithCode=true로 호출을 제거하면 이 버그의 가장 심각한 영향을 제거할 수 있지만, 코드 대신 리소스만 수정하는 것이 여전히 공격자에게 가치가 있을 수 있으므로 문제를 완전히 해결하지는 못한다는 점입니다.
LoadedApk.updateApplicationInfo()지금까지 제시된 코드는 RemoteViews(위젯, 알림 등)에만 사용되지만, 이제 새 스플릿이 설치된 후 실행 중인 앱 프로세스를 업데이트하는 데에도 사용되는 LoadedApk.updateApplicationInfo()를 살펴보겠습니다(예: Play Feature Delivery 주문형 전달 사용 시).
이제 RemoteViews의 신뢰할 수 없는 ApplicationInfo 객체가 updateApplicationInfo()에 전달됩니다. 해당 메서드가 무엇을 하는지 살펴보겠습니다 (스니펫 소스)```java
public void updateApplicationInfo(@NonNull ApplicationInfo aInfo,
@Nullable List oldPaths) {
if (!setApplicationInfo(aInfo)) {
return;
}
`setApplicationInfo()` [(스니펫 소스)](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;
}
createTimestamp 필드는 일반적으로 SystemClock.uptimeMillis()로 설정되지만, 이 객체는 공격자로부터 오는 것이므로, 공격자가 미래의 값을 제공하여 이후의 updateApplicationInfo() 호출이 실행되지 않도록 방지할 수 있음을 의미합니다
updateApplicationInfo()로 돌아가서 (스니펫 소스)```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);
`addedPaths` 목록이 생성됩니다: 새 분할(split) 설치 시 `oldPaths`에는 새 분할 설치 전에 사용된 경로 목록이 포함되고, `addedPaths`에는 기존 `ClassLoader`에 추가해야 하는 `.apk` 파일 목록이 포함됩니다. 그러나 `checkAndUpdateApkPaths()`의 경우 `oldPaths`는 정확히 동일한 `ApplicationInfo` 객체에서 생성되므로 `addedPaths`는 비어 있게 되고, 공격자는 여기서 기존 `ClassLoader`에 새 경로를 추가할 수 없습니다.
`createOrUpdateClassLoaderLocked()`는 긴 메서드이지만, 여기서 흥미로운 부분은 몇 가지만 있습니다:
* [`mIncludeCode`가 `false`인 경우, `apk`/`dex` 파일을 참조하지 않고 `ClassLoader`를 생성하는 것만 수행됩니다](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=968-990;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1)
* 가장 위험한 부분은 공격자가 제어하는 `ApplicationInfo`로 방금 설정된 경로를 사용하여 새 `ClassLoader`를 생성하는 것이지만, 이는 [`mDefaultClassLoader`가 `null`인 경우에만 발생합니다](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=1005;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1). 즉, 해당 `LoadedApk` 인스턴스에서 첫 번째 `createOrUpdateClassLoaderLocked()` 호출임을 의미합니다 (`mDefaultClassLoader` 필드는 [`AppComponentFactory.instantiateClassLoader()`](https://developer.android.com/reference/android/app/AppComponentFactory#instantiateClassLoader(java.lang.ClassLoader,%20android.content.pm.ApplicationInfo)) 적용 전에 사용된 `ClassLoader` 인스턴스를 참조합니다)
* 또한 해당 `ClassLoader`의 네이티브 라이브러리 검색 경로에 새 경로를 추가하는 부분도 있습니다](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=1050-1058;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1). 그러나 이를 악용하려면 피해 앱이 일반적으로 존재하지 않는 `System.loadLibrary()`를 호출해야 합니다
* 그리고 [`addedPaths` 인수의 `apk`/`dex` 경로를 기존 `ClassLoader`에 추가하는 부분도 있습니다](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=1060-1065;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1). 그러나 `RemoteViews`의 코드 경로에서는 `addedPaths`가 비어 있게 됩니다
다시 `updateApplicationInfo()`로 돌아가면, 이 메서드가 수행하는 또 하나의 관련 작업이 있습니다. 바로 [`LoadedApk.mResources`를 새 `mResDir` 값을 사용하는 인스턴스로 교체하는 것](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/LoadedApk.java;l=382;drc=5916ee589c4880e2d8a1a9ad6dc852108e4c44c1)입니다. 이는 새 인스턴스이며, 이미 존재하는 인스턴스를 업데이트하는 것이 아니라는 점에 유의해야 합니다. 새로 생성된 `Context`는 이 새 `Resources` 인스턴스를 반환/사용하지만, 이미 생성된 `Context`는 계속 이전 `Resources`를 사용합니다.
# 영향 요약
위 섹션들을 요약하면, 피해 프로세스가 `RemoteViews`를 적용할 때마다 이 취약점을 통해 다음이 가능합니다:
* 해당 프로세스 내에서 새로 생성된 Activity의 [`Resources`(현지화 문자열, 레이아웃 등)](https://developer.android.com/guide/topics/resources/providing-resources)를 교체
* `System.loadLibrary()`에서 사용하는 네이티브 라이브러리 검색 경로에 추가. 단, 이를 악용하려면 피해자가 일반적으로 존재하지 않는 라이브러리 이름을 전달하여 `System.loadLibrary()`를 호출해야 하므로 가능성은 낮습니다
* 피해 프로세스가 `createPackageContext(CONTEXT_INCLUDE_CODE)`를 사용했지만 해당 `Context`에서 [`getClassLoader()`를 호출하지 않은 경우](https://developer.android.com/reference/android/content/Context#getClassLoader()) 임의 Java 코드를 로드. 그러나 코드 로딩이 `CONTEXT_INCLUDE_CODE` 플래그를 사용하는 이유이므로, 이 역시 자연스럽게 발생할 가능성은 낮습니다
이 경우 Java 코드를 로드할 가능성은 자연스럽게 발생하기 어렵지만, 저는 이를 트리거할 수 있었습니다.
# `WebView` 로딩
최신 Android 버전에서 `WebView`는 시스템의 일부가 아니라 [시스템 구성](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/res/res/xml/config_webview_packages.xml)에 정의된 일반 `apk`에서 로드되며, [시스템 앱이거나 시스템 내 정의된 서명과 일치하는 서명을 가집니다](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)
해당 `apk`의 로딩은 [새 `Context`를 생성하고 `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)
이제 해당 메서드의 호출자를 살펴보겠습니다 [(스니펫 소스)](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()는 Context를 생성하는 메서드이며, Resources.registerResourcePaths()는 우리가 다른 스레드에서 checkAndUpdateApkPaths() 호출을 수행하고 임의의 Java 코드를 로드할 수 있도록 지연을 도입할 수 있는 유일한 기회입니다. webViewContext.getClassLoader()는 mIncludeCode가 true이고 mDefaultClassLoader가 null인 LoadedApk가 존재하는 창(window)의 끝이기 때문입니다.
Resources.registerResourcePaths()는 appendLibAssetsLocked()에 도달하며, 이 메서드는 싱글턴의 mResourceImpls 필드를 반복합니다. 이 필드에는 이 프로세스에 로드된 모든 리소스가 포함되므로, RemoteViews를 통해 심어진 ApplicationInfo를 사용하여 빌드된 리소스도 포함될 수 있습니다.
제 초기 아이디어는 ApplicationInfo에 모두 동일한 hashCode()를 가진 많은 수의 오버레이 경로를 넣는 것이었습니다. 이렇게 하면 createNewResourceKeyIfNeeded() 호출에서 중복 제거가 느려질 것입니다. 인터프리터를 사용할 때(예: 디버거를 사용하거나 새로 설치된 앱 내에서)는 이 지연이 상당했지만, 런타임이 최적화를 수행한 후에는 해시 충돌로 인한 지연이 크지 않았습니다. 그러나 또 다른 속도 저하 원인이 나타났습니다: 이러한 오버레이 경로가 존재하는 파일을 가리키지 않았기 때문에, 로드에 실패한 모든 오버레이에 대해 스택 트레이스가 포함된 로그 메시지가 출력되었고, 실제로 이로 인해 속도가 저하되어 이 경쟁 조건의 악용이 가능해졌습니다.
앱은 알림에서 RemoteViews를 SystemUI에 전달할 수 있습니다. 제 악용 앱은 POST_NOTIFICATIONS 권한을 요청하는데, 이는 합리적인 사용자 상호작용이라고 생각합니다. 다만 일부 알림은 해당 요구 사항을 우회할 수도 있습니다.
일반적으로 SystemUI는 WebView를 사용하지 않으며, 실제로 SystemUI는 기기 보호 저장소(device protected storage)를 사용(즉, 잠금 화면 자격 증명으로 보호되지 않는 저장소)하고 WebView 구현이 이를 허용하지 않기 때문에 사용할 수도 없습니다.
하지만 이 악용을 통해 Resources를 수정할 수 있으므로, 먼저 SlicePermissionActivity에서 사용하는 레이아웃에 <WebView /> 요소를 포함하도록 수정한 다음 해당 Activity를 실행하여 SystemUI 메인 스레드에서 WebView 초기화를 트리거했습니다.
그런 다음 코드를 로드하는 데 사용되는 경로를 교체하기 위해 다른 스레드에서 RemoteViews.getContextForResourcesEnsuringCorrectCachedApkPaths()가 호출되도록 해야 합니다.
일반적으로 알림 게시에는 SystemUI 프로세스의 여러 스레드가 관여하며, 여기에는 메인 스레드도 포함됩니다(따라서 WebView가 초기화되는 동안에는 다른 알림을 게시할 수 없습니다). 그러나 RemoteViews의 적용은 별도의 스레드에서 발생하며, RemoteViews 내에 ImageView를 두고 제 ContentProvider에서 이미지를 로드하도록 요청하여 해당 작업을 일시 중지할 수 있습니다.
이 악용은 SlicePermissionActivity에서 사용되는 레이아웃을 교체하여 WebView를 로드합니다. 코드 업데이트가 불가능하더라도 SlicePermissionActivity를 공격하는 것은 여전히 가치가 있습니다. 공격자는 레이아웃을 교체하여 원래 메시지를 완전히 숨기고 예를 들어 변경 로그를 표시하거나, 허용 버튼을 "알겠습니다"로 바꾸고 거부 버튼을 빈 문자열로 만들어 사실상 보이지 않게 만들 수 있기 때문입니다. Slices 프레임워크는 더 이상 사용되지 않지만, 여전히 Settings 슬라이스가 있으며, 예를 들어 사용자 상호작용 없이 모바일 데이터 설정을 변경할 수 있습니다.
SystemUI 내의 또 다른 중요한 권한 프롬프트는 Media Projection 확인이지만, 이는 Dialog에 애플리케이션 컨텍스트를 사용하므로 취약하지 않았습니다.
또한 Preference 프래그먼트도 흥미롭습니다. Preference에 의해 실행될 Intent를 정의할 수 있기 때문입니다. 그러나 SystemUI의 경우 Preference Activity는 SystemUI Tuner 및 Demo Mode와 관련된 것뿐이며, 이들은 알림을 표시하는 프로세스와 다른 프로세스에서 실행됩니다.