Skip to content

Writeup and exploit for CVE-2025-22441: Privilege escalation from installed app to SystemUI process on Android due to pass of untrusted ApplicationInfo to LoadedApk

Notifications You must be signed in to change notification settings

michalbednarski/ResourcePoison

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Fix for this issue has appeared as CVE-2025-22441: bulletin patch follow up

Passing ApplicationInfo around

ApplicationInfo is structure defining various information about installed app, most notably path to apk file from which resources and code are loaded

Usually it is passed from system to applications, however sometimes there are cases where non-system caller could provide own one. For example in the past that was vulnerability in bindBackupAgent() method, where attacker could pass in parameter own ApplicationInfo object with uid and sourceDir values and they weren't checked against what apps are really installed in system, because that method was meant to be called internally by system_server, but was exposed to adb shell

This time though, I've looked closely at ApplicationInfo field within RemoteViews

RemoteViews is object describing view that can come from another process. This most notably is used for home screen widgets, where app providing widget builds RemoteViews and then it is "applied" within home screen process

Other places where RemoteViews are used are notifications (applied by SystemUI process) and autofill dialogs (provided by autofill service, applied by system_server)

RemoteViews.mApplication field is serialized through Parcel and therefore may come from remote processes and whenever RemoteViews are applied it is used by following method (snippet source):

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;
}

Most interesting here is LoadedApk.checkAndUpdateApkPaths() call, as this is static method and will modify some global state (snippet source)

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);
}

Lets discuss what is going on in these methods

First we're calling 3-parameter checkAndUpdateApkPaths with cacheWithCode being set to both true and false. The LoadedApk object can be constructed in two modes, either with mIncludeCode being true or false, which SDK-wise maps to Context with CONTEXT_INCLUDE_CODE flag being set or not

That method will first use ActivityThread.peekPackageInfo(), which will return already cached LoadedApk instance, therefore if app didn't previously construct LoadedApk with matching packageName and includeCode, peekPackageInfo() will return null and checkAndUpdateApkPaths() won't do anything

Looking back at RemoteViews.getContextForResourcesEnsuringCorrectCachedApkPaths(), we have context.createApplicationContext(mApplication, Context.CONTEXT_RESTRICTED) there, the CONTEXT_INCLUDE_CODE flag was not specified (same is the case with contexts passed in argument to that method) and therefore that method will always using LoadedApk with mIncludeCode=false

Therefore, for RemoteViews updating version with code is unnecessary, as RemoteViews are always using Context without code. Commit message just says that there are two places where ApplicationInfo may be cached and I think removing updating version with code wouldn't cause issues here (as checkAndUpdateApkPaths() is only used by AppWidgetHostView and RemoteViews). Counterargument to that though is avoiding possibility of getting various caches out of sync and that in itself while removing call with cacheWithCode=true removes most severe impact of this bug, it doesn't completely fix issue as modification of just resources (instead of code) still might be valuable to attacker

LoadedApk.updateApplicationInfo()

So far presented code is only used for RemoteViews (widgets, notifications, etc), but now we'll be entering LoadedApk.updateApplicationInfo() which is also used for updating running app processes after new split has been installed (for example when using Play Feature Delivery on-demand delivery)

Now untrusted ApplicationInfo object from RemoteViews will be passed to updateApplicationInfo(), lets take a look what that method does (snippet source)

public void updateApplicationInfo(@NonNull ApplicationInfo aInfo,
        @Nullable List<String> oldPaths) {
    if (!setApplicationInfo(aInfo)) {
        return;
    }

Let's take a look at that setApplicationInfo() (snippet source)

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 field is normally set to SystemClock.uptimeMillis(), but as this object is coming from attacker, this means that attacker can provide future value to prevent further updateApplicationInfo() calls from executing

Returning to updateApplicationInfo() (snippet source)

final List<String> newPaths = new ArrayList<>();
makePaths(mActivityThread, aInfo, newPaths);
final List<String> 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);

List of addedPaths is built: in case of installation of new split oldPaths would contain list of paths that were used before installation of new split and addedPaths would contain list of .apk files that have to be added to existing ClassLoader, however in case of checkAndUpdateApkPaths() oldPaths will be built from exactly same ApplicationInfo object and therefore addedPaths will be empty and attacker won't be able to add new paths to existing ClassLoader here

createOrUpdateClassLoaderLocked() is long method, but only few things are interesting here:

Returning to updateApplicationInfo() again, there's still one relevant thing that this method does, replacing LoadedApk.mResources with instance that uses new mResDir value. It should be noted that this is new instance, not updating instances that are already present. Newly created Contexts will return/use this new Resources instance, however any already created Contexts will keep using old Resources

Impact summary

To summarize above sections, whenever victim process applies RemoteViews, this vulnerability allows to:

  • Replace Resources (localization strings, layouts, etc) of newly created Activities within that process
  • Append to native library search path used by System.loadLibrary(), however exploiting that would require victim to call System.loadLibrary() passing name of library that is normally absent, which is unlikely
  • Load arbitrary Java code if victim process has used createPackageContext(CONTEXT_INCLUDE_CODE), but didn't call getClassLoader() on that Context, however since loading code is reason to use CONTEXT_INCLUDE_CODE flag, this is also unlikely to happen naturally

Now, while chance to load Java code in this case is unlikely to happen naturally, I was able to trigger it

Loading the WebView

On modern Android versions, WebView is not part of system, but loaded from normal apk that is defined in system configuration and is either system app or has signature matching one defined within system

Loading of that apk is done by creating new Context, passing Context.CONTEXT_INCLUDE_CODE | Context.CONTEXT_IGNORE_SECURITY flags

Let's now take a look at caller of that method (snippet source)

// 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() is method that creates Context, Resources.registerResourcePaths() is our only chance to introduce delay for us to have checkAndUpdateApkPaths() call in another thread and load arbitrary Java code, as webViewContext.getClassLoader() is the end of window where there's LoadedApk with mIncludeCode being true and mDefaultClassLoader being null

Resources.registerResourcePaths() will reach to appendLibAssetsLocked(), which will iterate over mResourceImpls field of singleton, which contains all resources that were loaded into this process and therefore may contain resources built using ApplicationInfo planted through RemoteViews

My initial idea was to put in ApplicationInfo large number of overlays paths all of which have same hashCode() which would be slow to deduplicate by createNewResourceKeyIfNeeded() call and while when using interpreter (for example when using debugger or within freshly installed app) this was significant delay, when runtime has done optimizations delay introduced by hash collisions wasn't significant, however another slowdown reason has appeared: since these overlay paths didn't point to existing files, for every overlay that failed to load a log message with stack trace was printed and that in practice did lead to slowdown, making exploitation of this race condition feasible

Getting into SystemUI

Apps can pass RemoteViews to SystemUI in Notifications. My exploit app requests POST_NOTIFICATIONS permission, which I think is reasonable user interaction, although it might be possible for some notifications to bypass that requirement

Normally SystemUI doesn't use WebView and in fact it cannot even do so, as SystemUI uses device protected storage (that is, storage that is not protected by lock screen credential) and WebView implementation disallows that

This exploit allows me to modify Resources though, so first I've modified layout used by SlicePermissionActivity to include <WebView /> element, then launched that Activity, which triggers WebView initialization on SystemUI main thread

Then I have to get RemoteViews.getContextForResourcesEnsuringCorrectCachedApkPaths() called on other thread in order to replace path used to load code

Normally posting notification involves multiple threads in SystemUI process, including main thread (so while WebView is being initialized I cannot post another notification), however application of RemoteViews is happening on separate thread and I can suspend that operation by having ImageView within RemoteViews and asking it to load image from my ContentProvider

Resource-only attacks

This exploit loads WebView by replacing layout used in SlicePermissionActivity. If updating code was not possible, attacking SlicePermissionActivity could still be valuable, as attacker could replace layout to fully hide original message and for example show changelog, replace allow button with "Got it" and deny button with empty string making it effectively invisible. While Slices framework is deprecated, there are still Settings slices which can for example change mobile data setting without user interaction

Other important permission prompt within SystemUI is Media Projection confirmation, however that one happened to not be vulnerable as it uses application context for Dialog

Also interesting are Preference fragments, as you can define Intent to be launched by Preference, however in case of SystemUI only preference Activities are ones related to SystemUI Tuner and Demo Mode, but these run in different process than one that shows Notifications

About

Writeup and exploit for CVE-2025-22441: Privilege escalation from installed app to SystemUI process on Android due to pass of untrusted ApplicationInfo to LoadedApk

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages