Security disclosures

GameManagerService: two unguarded Binder methods leak cross-profile game activity to any app

Researcher
Ayush Kumar
Program
Android & Google Devices VRP
Android ID
491427633
Reported
2026-03-10

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

Source on cs.android.com

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, no SecurityException
  • CALLBACK entries carrying packageName, userId, mode, label, quality for state changes across every user profile
  • setGameState accepted for installed games the app does not own — no exception, no validation
  • Server-side FrameworkStatsLog.GAME_STATE_CHANGED entries 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.