Security disclosures

MediaSessionService: any app can name a class and have system_server construct it

Researcher
Ayush Kumar
Program
Android & Google Devices VRP
Android ID
492815504
Reported
2026-03-15

Android ID 492815504 · Reported to the Android & Google Devices VRP, 15 March 2026


1. Summary

MediaSessionService exposes two Binder methods that take a class name as a string and instantiate it inside system_server:

void setCustomMediaKeyDispatcher(String name);
void setCustomMediaSessionPolicyProvider(String name);

Neither performs a permission check. Both reach Class.forName(name).getDeclaredConstructor(Context.class).newInstance(mContext) — running an attacker-chosen constructor at UID 1000, with the real system Context.

Three details turn that from a bad idea into a usable primitive:

  1. The type check happens after construction. The cast to MediaKeyDispatcher is applied to the result of newInstance(), so a constructor's side effects have already completed by the time the cast fails.
  2. ClassCastException is not caught locally. The catch block lists five reflection exceptions and not that one, so the failed cast escapes as a RuntimeException — which Binder catches during transact handling. system_server therefore survives each call.
  3. Surviving makes it repeatable. The caller gets a failed transact and can immediately call again, as many times as it likes.

So the attacker does not need a class that is a MediaKeyDispatcher. They need a class whose constructor does something useful, and the cast failing afterwards costs them nothing.

On the tested device, 35 of 35 candidate gadget classes constructed successfully.


2. Affected surface

Property Detail
Service com.android.server.media.MediaSessionService$SessionManagerImpl
AIDL frameworks/base/media/java/android/media/session/ISessionManager.aidl (lines 77–78)
Implementation MediaSessionService.java — entry points at 2434 / 2439, reflection at 1205 / 1225
Attacker position Any installed app
Permissions required None
User interaction Install and run
Tested on Redmi Note 13 Pro (garnet_in), Android 15, AQ3A.240912.001, HyperOS OS2.0.207.0.VNRINXM

Source on cs.android.com


3. Root cause

3.1 No authorisation on either entry point

@Override
public void setCustomMediaKeyDispatcher(String name) {
    instantiateCustomDispatcher(name);
}

@Override
public void setCustomMediaSessionPolicyProvider(String name) {
    instantiateCustomProvider(name);
}

The service is not careless elsewhere. addOnMediaKeyEventDispatchedListener requires MEDIA_CONTENT_CONTROL; setOnVolumeKeyLongPressListener and registerRemoteSessionCallback are both permission-gated. These two methods are the exception.

3.2 The cast is after the constructor, and the wrong exception is caught

private void instantiateCustomDispatcher(String componentName) {
    synchronized (mLock) {
        mCustomMediaKeyDispatcher = null;
        try {
            if (componentName != null && !TextUtils.isEmpty(componentName)) {
                Class customDispatcherClass = Class.forName(componentName);
                Constructor constructor =
                        customDispatcherClass.getDeclaredConstructor(Context.class);
                mCustomMediaKeyDispatcher =
                        (MediaKeyDispatcher) constructor.newInstance(mContext);
            }
        } catch (ClassNotFoundException | InstantiationException | InvocationTargetException
                | IllegalAccessException | NoSuchMethodException e) {
            mCustomMediaKeyDispatcher = null;
            Log.w(TAG, "Encountered problem while using reflection", e);
        }
    }
}

Read the order of operations. newInstance(mContext) runs the constructor; only then is the result cast. Whatever that constructor did — spawned threads, published a Binder service, wrote a file, registered a receiver — has already happened.

ClassCastException is absent from the catch list. It propagates out as a RuntimeException, Binder catches it at the transact boundary, and the process lives. The failed cast is not a defence; it is the cleanup step that happens too late.

The constructor runs before the type check, and the failed check does not stop anythingAn app with no declared permissions calls setCustomMediaKeyDispatcher with the name of a class it chooses. Inside system_server the service calls Class.forName on that name, then getDeclaredConstructor for a constructor taking a Context, then newInstance, which executes the attacker-chosen constructor with the real system Context. Any side effects in that constructor — spawning threads, publishing a Binder service, writing or deleting files under /data/system, registering receivers — complete at this point. Only afterwards is the result cast to MediaKeyDispatcher, and for a class that is not one the cast throws ClassCastException. That exception is not in the local catch list, which names ClassNotFoundException, InstantiationException, InvocationTargetException, IllegalAccessException and NoSuchMethodException, so it escapes as a RuntimeException and Binder catches it at the transact boundary. system_server therefore survives, the caller receives a failed transaction, and the same call can be made again without limit.App with zero declared permissionssetCustomMediaKeyDispatcher("…IMMS$Lifecycle")Binderbelow: system_serverClass.forName(name)getDeclaredConstructor(Context.class)newInstance(mContext)the attacker’s constructor executes here, as UID 1000side effects complete — irreversiblethreads spawned · Binder service published/data/system written and deleted · receivers registered(MediaKeyDispatcher) … → ClassCastExceptionthe check the cast was meant to be, arriving after the factcaught locallyClassNotFound · Instantiation · InvocationTargetIllegalAccess · NoSuchMethodClassCastException — not in the listBinder catches it at the transact boundarysystem_server survives → the caller simply calls againrepeatable
The cast to MediaKeyDispatcher is applied to what newInstance() returned — so by the time it fails, the attacker’s constructor has already run inside system_server. And because ClassCastException is not in the local catch list, the failure escapes to Binder rather than to the service: the process survives, and the call can be repeated without limit.

3.3 What does not work, and why that matters

instantiateCustomDispatcher() does not call Binder.clearCallingIdentity() before newInstance(). The calling identity is still the attacking app, which splits the gadget space in a way worth being precise about:

  • Reliable: side effects local to system_server — thread creation, in-process listener registration, file I/O, Binder service publication through the system-service path.
  • Unreliable: constructors that immediately make permission-checked or multi-user Binder calls, because those checks still see the caller's identity.

This is why the finding rests on confirmed gadgets rather than on the existence of (Context) constructors in general.


4. Confirmed device impact

4.1 system_server crash, and a boot loop

Gadget: InputMethodManagerService$Lifecycle — each construction spawns two service threads, and the attack is repeatable.

At roughly 500 calls, system_server hit thread and stack exhaustion:

03-10 20:59:07.779 F libc    : shadow stack read-write mprotect(...) failed: Out of memory
03-10 20:59:07.781 F libc    : Fatal signal 6 (SIGABRT) ... pid 16260 (system_server)
03-10 20:59:20.283 E AndroidRuntime: *** FATAL EXCEPTION IN SYSTEM PROCESS
03-10 20:59:20.283 E AndroidRuntime: java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed

The PID change is the proof the process restarted: 16260 → 26025.

With a BOOT_COMPLETED / LOCKED_BOOT_COMPLETED receiver restarting the flood, a one-shot crash becomes a boot loop until the app is removed or the device is booted into Safe Mode.

4.2 Replacing a core Binder service

Gadget: ResourcesManagerService, whose constructor publishes a Binder service before onStart() ever runs:

public ResourcesManagerService(@NonNull Context context) {
    super(context);
    publishBinderService(Context.RESOURCES_SERVICE, mService);
}

publishBinderService(...) delegates to ServiceManager.addService(...). Constructing this class through the reflection path therefore registers a phantom resources service, displacing the real one:

SUCCESS — ResourcesManagerService phantom registered
Real 'resources' service is permanently broken until reboot.

service check resources still reports the service present; service call resources 1 fails with an NPE inside the phantom. The service is not missing — it is wrong, which is harder to notice.

4.3 Writes, deletes and injected receivers

Gadget Constructor side effect
MediaQualityService creates a SQLite DB and shared prefs under /data/system/media_quality/
BitmapOffloadService deletes the contents of /data/system/offloaded-bitmaps/
AutofillManagerService receiver registration and listener setup inside system_server
ProfcollectForwardingService registers receivers for several system intents
SupervisionService user-lifecycle and role listener setup
UsbService policy-state receiver registration
StorageStatsService installer and listener wiring

A six-phase run on the device:

Phase 1/6: SERVICE_HIJACK   — ResourcesManagerService                        SUCCESS
Phase 2/6: FILE_WRITE       — MediaQualityService                            SUCCESS
Phase 3/6: RECEIVER_INJECT  — AutofillManagerService + Profcollect           SUCCESS
Phase 4/6: STATE_MODIFY     — SupervisionService + UsbService                SUCCESS
Phase 5/6: FILE_DELETE      — BitmapOffloadService                           SUCCESS
Phase 6/6: SYSTEM_CRASH     — IMMS$Lifecycle, 500 calls                      SUCCESS

And a census of candidate gadgets: 35 / 35 constructed successfully, 0 blocked.


5. Why the census is the point

The individual gadgets are interchangeable. What matters is that the set is open.

Any class already on the system_server classpath with a (Context) constructor is a candidate, and every future one becomes a candidate the moment it is added — without anybody touching MediaSessionService. A gadget fixed in isolation removes one entry from a list nobody maintains.

That is why this is one vulnerability with many impacts rather than a bundle of separate bugs: the defect is the unauthenticated construction primitive, not any particular class it reaches.


6. What is not being claimed

No arbitrary code execution. Follow-up work looking for a credible ACE path did not find one, and the report says so rather than leaving the implication hanging.

The accurate framing is a local elevation-of-privilege primitive plus a persistent denial-of-service: unauthenticated execution of privileged constructor side effects inside system_server, with confirmed integrity impact (service replacement, system-directory writes and deletes) and confirmed availability impact (crash, and a boot loop with the persistence variant).

Classification: CWE-862 missing authorization, CWE-470 externally-controlled class selection, CWE-400 uncontrolled resource consumption.


7. Suggested fix

The submitted report included a patch. It gates both entry points, allowing root, system and shell for platform integration and testing, and requiring a signature-level permission of anyone else:

private void enforceCustomMediaComponentPermission(String apiName, String className) {
    final int callingUid = Binder.getCallingUid();

    // Root/system/shell are allowed for platform integration and testing.
    if (callingUid == Process.ROOT_UID
            || callingUid == Process.SYSTEM_UID
            || callingUid == Process.SHELL_UID) {
        return;
    }

    // Signature-level gate for external callers.
    mContext.enforceCallingPermission(Manifest.permission.MEDIA_CONTENT_CONTROL, apiName);
}

Two changes are worth making alongside it, because each independently breaks the primitive:

  • Check the type before running the constructor. Class.isAssignableFrom(...) on the loaded class, before newInstance(), means a non-conforming class never constructs.
  • Catch ClassCastException locally. It does not stop the side effects, but it stops the failure escaping to Binder in a shape that leaves the caller free to retry.

8. Disclosure timeline

Date Event
2026-03-15 Reported with proof of concept, evidence logs and a proposed AOSP patch
2026-03-15 Triaged
2026-07-17 Assessed against an existing internal investigation; Google confirms the canonical issue was closed without a patch, and that there is no Security Bulletin to wait for
2026-07-17 Google invites a draft of this writeup for technical-accuracy review ahead of publication
2026-09-12 Draft shared for that review
2026-09-12 Google reply: reviewed, no objections to publishing

Disclosure status. Published with Google's agreement, after the technical-accuracy review they offered.

Thank you for sharing the draft of your write-up. We have reviewed it and we have no objections to you publishing it. Since the canonical issue was closed as "Won't Fix", there is no security patch that we need to wait for.


Reported by Ayush Kumar to the Android & Google Devices Vulnerability Reward Program. Published under coordinated disclosure.