GameManagerService: two unguarded Binder methods leak cross-profile game activity to any app
Android ID 491427633 · Reported to the Android & Google Devices VRP, 10 March 2026
1. Summary
GameManagerService exposes two Binder methods with no permission check and no caller
validation. An app holding zero declared permissions can register a listener that
receives real-time game-activity callbacks for every user on the device — including
work profiles and secondary users — and can set game state on behalf of any installed
game it does not own.
What makes this more than an oversight is the neighbourhood. Each unguarded method sits beside a structurally identical sibling in the same file that is guarded. The service already knows how to protect this surface; these two methods simply do not do it.
| Method | Guard | Result from an unprivileged app |
|---|---|---|
addGameModeListener |
checkPermission(MANAGE_GAME_MODE) |
SecurityException — correct |
addGameStateListener |
none | succeeds |
getGameMode |
isValidPackageName(), falling back to MANAGE_GAME_MODE |
own package only — correct |
setGameState |
isPackageGame() only |
succeeds for any installed game |
2. Affected surface
| Property | Detail |
|---|---|
| Component | frameworks/base/services/core/java/com/android/server/app/GameManagerService.java |
| Affected versions | Android 14+ (from the introduction of IGameStateListener) |
| Confirmed on | Android 15 (SDK 35), physical device |
| Source verified at | AOSP tag android-16.0.0_r4 |
| Attacker position | Any installed app, untrusted_app SELinux context |
| Permissions required | None |
| User interaction | Install only |
Reproduction device:
Redmi/garnet_in/garnet:15/AQ3A.240912.001/OS2.0.207.0.VNRINXM:user/release-keys
Redmi Note 13 Pro (2312DRA50I) · Android 15 (SDK 35)
PoC UID 10631 · SELinux context untrusted_app
User profiles present: 0 (owner), 10, 11, 999
3. Root cause
Both defects are the same mistake made twice: a method was added to a service without picking up the enforcement pattern every other sensitive method in that service already uses.
3.1 addGameStateListener — no permission check at all
Line references are from AOSP main as verified at android-16.0.0_r4.
// GameManagerService.java:1524
@Override
public void addGameStateListener(@NonNull IGameStateListener listener) {
// No checkPermission() call.
try {
final IBinder listenerBinder = listener.asBinder();
listenerBinder.linkToDeath(new DeathRecipient() {
@Override public void binderDied() {
removeGameStateListenerUnchecked(listener);
listenerBinder.unlinkToDeath(this, 0);
}
}, 0);
synchronized (mGameStateListenerLock) {
mGameStateListeners.put(listener, Binder.getCallingUid());
}
} catch (RemoteException ex) { /* ... */ }
}
The sibling forty-six lines earlier does it correctly:
// GameManagerService.java:1478
@Override
@RequiresPermission(Manifest.permission.MANAGE_GAME_MODE)
public void addGameModeListener(@NonNull IGameModeListener listener) {
checkPermission(Manifest.permission.MANAGE_GAME_MODE);
// ...identical registration logic
}
The omission is mirrored in the AIDL, so nothing downstream re-imposes it:
@JavaPassthrough(annotation="@android.annotation.RequiresPermission(
android.Manifest.permission.MANAGE_GAME_MODE)")
void addGameModeListener(IGameModeListener gameModeListener);
void addGameStateListener(IGameStateListener gameStateListener); // no annotation
3.2 setGameState — package checked, caller not
// GameManagerService.java:484
public void setGameState(String packageName, @NonNull GameState gameState,
@UserIdInt int userId) {
if (!isPackageGame(packageName, userId)) {
Slog.d(TAG, "No-op for attempt to set game state for non-game app: " + packageName);
return;
}
// Missing: isValidPackageName(packageName, userId) — any caller is accepted.
final Message msg = mHandler.obtainMessage(SET_GAME_STATE);
// ...
}
isPackageGame() asks whether the target is a game. It never asks whether the caller
owns that target — so the only check present validates the object of the call, not the
subject. getGameMode shows the intended shape:
// GameManagerService.java:1095
if (!isPackageGame(packageName, userId)) {
return GameManager.GAME_MODE_UNSUPPORTED;
}
if (isValidPackageName(packageName, userId)) {
return getGameModeFromSettingsUnchecked(packageName, userId);
}
checkPermission(Manifest.permission.MANAGE_GAME_MODE);
return getGameModeFromSettingsUnchecked(packageName, userId);
3.3 The callback crosses user boundaries
// IGameStateListener.aidl
oneway void onGameStateChanged(String packageName, in GameState state, int userId);
The dispatch handler iterates every registered listener without filtering by userId,
and the callback carries that userId in its payload. So a listener registered by an app
in the primary user is notified about activity in work profiles and secondary users, and is
told which profile it came from.
4. What the access is worth
Cross-profile activity monitoring without consent. The listener yields, in real time,
which game is running, on which profile, and in what state. That is functionally the
information PACKAGE_USAGE_STATS protects — and that permission cannot be granted silently;
the user has to grant it through a dedicated Settings screen. Here the same class of signal
arrives with no permission, no prompt, and no trace in the app's manifest. The profile
boundary is the sharper edge: work-profile separation exists precisely so a personal app
cannot observe work-side activity.
Telemetry attributed to someone else. setGameState on a package the caller does not
own writes FrameworkStatsLog.GAME_STATE_CHANGED entries attributed to the target game.
The attacker writes; the victim's package is named as the source.
Reaching the HAL. Where the target game has GAME_MODE_PERFORMANCE enabled,
setGameState(isLoading=true) drives setPowerMode(Mode.GAME_LOADING) at the hardware
abstraction layer. An unprivileged caller influencing device power state through a path it
has no claim to is a longer reach than the rest of this finding.
5. Proof of concept
A zero-permission app. Its manifest declares nothing; the results are purely what the Binder interface hands out.
cd poc/ && ./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb logcat -c
adb shell am start -n com.poc.gamemanagerexploit/.GameManagerExploit
adb logcat -s GameMgrExploit GameManagerService
Observed:
addGameStateListener succeeded=true— registered, noSecurityExceptionCALLBACKentries carryingpackageName,userId,mode,label,qualityfor state changes across every user profilesetGameStateaccepted for installed games the app does not own — no exception, no validation- Server-side
FrameworkStatsLog.GAME_STATE_CHANGEDentries written with attacker-supplied values
The control is the important part: the same app calling the protected sibling
addGameModeListener receives a SecurityException, exactly as designed. Identical caller,
identical UID, identical context — the only variable is which method was called.
6. Suggested fix
Both are small and follow patterns already in the file.
// addGameStateListener — match the sibling.
@Override
@RequiresPermission(Manifest.permission.MANAGE_GAME_MODE)
public void addGameStateListener(@NonNull IGameStateListener listener) {
checkPermission(Manifest.permission.MANAGE_GAME_MODE);
// ...existing registration code
}
// setGameState — validate the caller, not just the target.
public void setGameState(String packageName, @NonNull GameState gameState,
@UserIdInt int userId) {
if (!isPackageGame(packageName, userId)) { return; }
if (!isValidPackageName(packageName, userId)) {
checkPermission(Manifest.permission.MANAGE_GAME_MODE);
}
// ...existing code
}
The AIDL annotation on addGameStateListener should be added alongside, so the declared
contract and the enforced one agree.
Separately, the dispatch loop should filter listeners by userId rather than relying on the
registration check alone. Defence in depth: even a correctly permissioned listener has no
general need to observe every profile on the device.
7. Disclosure timeline
| Date | Event |
|---|---|
| 2026-03-10 19:33 | Report filed with PoC; Android ID 491427633 opened |
| 2026-03-10 19:34 | Assigned; coordinated-disclosure request issued |
| 2026-03-10 19:34 | Google asks who else knows about the issue and when disclosure is planned |
| 2026-03-10 19:37 | Reporter confirms the issue is private and unpublished |
| 2026-03-11 00:15 | Google asks that the report stay confidential until a fix ships in a public Android security bulletin; acknowledgement name recorded as Ayush Kumar |
| 2026-03-25 00:15 | Report closed |
| 2026-09-12 | Draft of this writeup shared for review, asking whether Google objected to publication given that no bulletin could ever be scheduled |
| 2026-09-12 | Google reply: no objections to publishing |
Disclosure status. Published with Google's agreement.
The original instruction was to hold until a fix shipped in a public Android security bulletin. The report was then closed as not a security vulnerability, so no bulletin would ever exist and the condition could not be met by waiting. Rather than act on that reading alone, the draft was shared and the question put directly. Google's reply:
Because our team determined that this report does not qualify as a security vulnerability, there will not be a security update for this issue in a public Android security bulletin. Therefore, we have no objections to you publishing your write-up at this time.
Reported by Ayush Kumar to the Android & Google Devices Vulnerability Reward Program. Published under coordinated disclosure.