CVE-2026-7352
Overview
Background
- `MediaDrmBridge`
- a Java object in
media/base/androidthat wraps Android’sMediaDrmDRM API and holds a pointer (mNativeMediaDrmBridge) back to the C++media::MediaDrmBridgeit calls into over JNI. - JNI callback
- a method such as
onPromiseResolved,onSessionMessage, oronProvisioningCompleteinvoked (often from a background/MediaDrmevent thread) that forwards results to the native object viaMediaDrmBridgeJni.get(). - `INVALID_NATIVE_MEDIA_DRM_BRIDGE`
- the sentinel value assigned to
mNativeMediaDrmBridgebydestroy()to mark that the native peer is gone. - TOCTOU (time-of-check/time-of-use)
- a race where a value is validated and then used, but is invalidated in between by another thread.
Root Cause Analysis
The pre-patch JNI helpers guarded native calls only with isNativeMediaDrmBridgeValid(), a plain read of mNativeMediaDrmBridge, before passing that pointer into MediaDrmBridgeJni.get(). Because destroy() ran on a different thread and simply wrote mNativeMediaDrmBridge = INVALID_NATIVE_MEDIA_DRM_BRIDGE with no synchronization, a callback could pass the valid check, then be preempted while the native media::MediaDrmBridge was destroyed, and finally dereference the now-freed pointer inside the JNI call — a classic TOCTOU use-after-free.
The invariant that was violated is that the native pointer must remain alive for the entire duration of any in-flight JNI call.
The fix introduces mNativeMediaDrmBridgeLock and a mPendingNativeCalls counter: runNativeCallback() snapshots the pointer and increments the counter under the lock, and destroy() now invalidates the pointer and then blocks (mNativeMediaDrmBridgeLock.wait()) until mPendingNativeCalls drops to zero. This makes destruction wait for all outstanding callbacks, closing the race window.
destroy() cannot proceed while a JNI call is in flight.Attack Path
- Set up DRM playback
Web content drives an EME/DRM session so that
MediaDrmBridgeand its native peer exist andMediaDrmevent callbacks (session messages, promise resolutions, provisioning) are being delivered on background threads. - Race a callback against teardown
Arrange for a JNI callback (e.g.
onSessionMessageoronPromiseResolved) to be in flight at the same moment the pipeline tears the bridge down, invokingdestroy(). - Win the TOCTOU window
The callback passes the old
isNativeMediaDrmBridgeValid()check, thendestroy()sets the pointer invalid and the nativemedia::MediaDrmBridgeis freed before the callback issues its JNI call. - Use-after-free
The callback dereferences the freed native pointer inside
MediaDrmBridgeJni.get(), operating on reclaimed memory (inferred downstream read/write primitive; the diff shows only the dangling dereference, not a specific corrupted field).
Impact Assessment
media::MediaDrmBridge in the browser/GPU-side media stack on Android, which the vendor advisory rates high severity. Exploitation requires winning a thread race between an in-flight JNI callback and bridge destruction. The advisory does not report in-the-wild exploitation, and any escalation from the UAF to code execution is (inferred), not demonstrated by the diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifmedia/base/android/java/src/org/chromium/media/MediaDrmBridge.java |
modified | |
synchronizedmedia/base/android/java/src/org/chromium/media/MediaDrmBridge.java |
modified | |
whilemedia/base/android/java/src/org/chromium/media/MediaDrmBridge.java |
modified |
Files Changed
media/base/android/java/src/org/chromium/media/MediaDrmBridge.java
Audit Directions
- Unsynchronized native-pointer validity checksAudit other
@CalledByNativeJava classes that store a native peer pointer and guard JNI calls with a simple validity read; these are TOCTOU-prone unless destruction and callbacks share a lock. - Destruction without draining in-flight workLook for
destroy()/release()paths that only flip a sentinel or null a pointer without waiting for outstanding asynchronous callbacks or posted tasks to complete. - Cross-thread DRM/media event deliveryReview components that receive Android framework callbacks on arbitrary threads and forward them to native objects, confirming the native lifetime is pinned for the call’s duration.
Patch
From 5625d2ec93730e93927ffad958fc3a0f90073218 Mon Sep 17 00:00:00 2001 From: Feras Aldahlawi <[email protected]> Date: Wed, 15 Apr 2026 17:59:47 -0700 Subject: [PATCH] cdm: Synchronize calls to MediaDrmBridge Introduce a synchronization lock and a pending call counter to ensure that MediaDrmBridge's destroy() method waits for all in-flight JNI callbacks to complete before proceeding with destruction. This prevents race conditions where a callback might attempt to use the native pointer after it has been invalidated. Bug: 499023054 Change-Id: I50838e4d0592a0b2c6ac6851d5f199c76a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7737728 Reviewed-by: Frank Liberato <[email protected]> Reviewed-by: Vikram Pasupathy <[email protected]> Commit-Queue: Feras Aldahlawi <[email protected]> Cr-Commit-Position: refs/heads/main@{#1615537} --- diff --git a/media/base/android/java/src/org/chromium/media/MediaDrmBridge.java b/media/base/android/java/src/org/chromium/media/MediaDrmBridge.java index 6a706db7..698fe564 100644 --- a/media/base/android/java/src/org/chromium/media/MediaDrmBridge.java +++ b/media/base/android/java/src/org/chromium/media/MediaDrmBridge.java @@ -102,6 +102,10 @@ // Reference to the C++ media::MediaDrmBridge object. private long mNativeMediaDrmBridge; + private int mPendingNativeCalls; + + private final Object mNativeMediaDrmBridgeLock = new Object(); + private final UUID mKeySystemUuid; private final boolean mRequiresMediaCrypto; @@ -642,7 +646,7 @@ // Provision only works for origin isolated storage. if (!mOriginSet) { Log.e(TAG, "Calling provision() without an origin."); - MediaDrmBridgeJni.get().onProvisioningComplete(mNativeMediaDrmBridge, false); + onProvisioningComplete(false); return; } @@ -663,12 +667,12 @@ } // Indicate that provisioning succeeded. - MediaDrmBridgeJni.get().onProvisioningComplete(mNativeMediaDrmBridge, true); + onProvisioningComplete(true); } catch (android.media.NotProvisionedException e) { if (!startProvisioning()) { // Indicate that provisioning failed. - MediaDrmBridgeJni.get().onProvisioningComplete(mNativeMediaDrmBridge, false); + onProvisioningComplete(false); } } } @@ -692,7 +696,18 @@ @CalledByNative private void destroy() { Log.i(TAG, "Destroying MediaDrmBridge for origin %s", mOrigin); - mNativeMediaDrmBridge = INVALID_NATIVE_MEDIA_DRM_BRIDGE; + synchronized (mNativeMediaDrmBridgeLock) { + mNativeMediaDrmBridge = INVALID_NATIVE_MEDIA_DRM_BRIDGE; + // If mPendingNativeCalls is greater than 0, that means there are threads still + // accessing this object. + while (mPendingNativeCalls > 0) { + try { + mNativeMediaDrmBridgeLock.wait(); + } catch (InterruptedException e) { + Log.e(TAG, "Interrupted while waiting for pending native calls", e); + } + } + } if (mMediaDrm != null) { try { release(); @@ -1399,9 +1414,7 @@ } Log.i(TAG, "Provisioning origin ID %s", mOriginSet ? mOrigin : "<none>"); - MediaDrmBridgeJni.get() - .onProvisionRequest( - mNativeMediaDrmBridge, request.getDefaultUrl(), request.getData()); + onProvisionRequest(request.getDefaultUrl(), request.getData()); return true; } @@ -1487,7 +1500,7 @@ void onProvisioned(boolean success) { if (!mRequiresMediaCrypto) { // No MediaCrypto required, so notify provisioning complete. - MediaDrmBridgeJni.get().onProvisioningComplete(mNativeMediaDrmBridge, success); + onProvisioningComplete(success); if (!success) { release(); } @@ -1538,50 +1551,89 @@ // Helper functions to make native calls. - private void onMediaCryptoReady(@Nullable MediaCrypto mediaCrypto) { - if (isNativeMediaDrmBridgeValid()) { - MediaDrmBridgeJni.get().onMediaCryptoReady(mNativeMediaDrmBridge, mediaCrypto); + private interface NativeCall { + void run(long nativeMediaDrmBridge); + } + + /** + * Safely executes a JNI call, ensuring the native pointer remains valid for the duration of the + * call and blocking destroy() until it completes. + */ + private void runNativeCallback(NativeCall call) { + long nativePtr; + synchronized (mNativeMediaDrmBridgeLock) { + if (mNativeMediaDrmBridge == INVALID_NATIVE_MEDIA_DRM_BRIDGE) return; + nativePtr = mNativeMediaDrmBridge; + mPendingNativeCalls++; } + + try { + call.run(nativePtr); + } finally { + synchronized (mNativeMediaDrmBridgeLock) { + mPendingNativeCalls--; + if (mPendingNativeCalls == 0 + && mNativeMediaDrmBridge == INVALID_NATIVE_MEDIA_DRM_BRIDGE) { + mNativeMediaDrmBridgeLock.notifyAll(); + } + } + } + } + + private void onMediaCryptoReady(@Nullable MediaCrypto mediaCrypto) { + runNativeCallback( + (nativePtr) -> MediaDrmBridgeJni.get().onMediaCryptoReady(nativePtr, mediaCrypto)); + } + + private void onProvisioningComplete(boolean success) { + runNativeCallback( + (nativePtr) -> MediaDrmBridgeJni.get().onProvisioningComplete(nativePtr, success)); + } + + private void onProvisionRequest(String defaultUrl, byte[] requestData) { + runNativeCallback( + (nativePtr) -> + MediaDrmBridgeJni.get() + .onProvisionRequest(nativePtr, defaultUrl, requestData)); } private void onPromiseResolved(final long promiseId) { - if (isNativeMediaDrmBridgeValid()) { - MediaDrmBridgeJni.get().onPromiseResolved(mNativeMediaDrmBridge, promiseId); - } + runNativeCallback( + (nativePtr) -> MediaDrmBridgeJni.get().onPromiseResolved(nativePtr, promiseId)); } private void onPromiseResolvedWithSession(final long promiseId, final SessionId sessionId) { - if (isNativeMediaDrmBridgeValid()) { - MediaDrmBridgeJni.get() - .onPromiseResolvedWithSession( - mNativeMediaDrmBridge, promiseId, sessionId.emeId()); - } + byte[] emeId = sessionId.emeId(); + runNativeCallback( + (nativePtr) -> + MediaDrmBridgeJni.get() + .onPromiseResolvedWithSession(nativePtr, promiseId, emeId)); } private void onPromiseRejected( final long promiseId, final long systemCode, final String errorMessage) { Log.e(TAG, "onPromiseRejected: %s", errorMessage); - if (isNativeMediaDrmBridgeValid()) { - MediaDrmBridgeJni.get() - .onPromiseRejected(mNativeMediaDrmBridge, promiseId, systemCode, errorMessage); - } + runNativeCallback( + (nativePtr) -> + MediaDrmBridgeJni.get() + .onPromiseRejected(nativePtr, promiseId, systemCode, errorMessage)); } private void onSessionMessage(final SessionId sessionId, final MediaDrm.KeyRequest request) { - if (!isNativeMediaDrmBridgeValid()) return; - int requestType = request.getRequestType(); - MediaDrmBridgeJni.get() - .onSessionMessage( - mNativeMediaDrmBridge, sessionId.emeId(), requestType, request.getData()); + byte[] requestData = request.getData(); + byte[] emeId = sessionId.emeId(); + runNativeCallback( + (nativePtr) -> + MediaDrmBridgeJni.get() + .onSessionMessage(nativePtr, emeId, requestType, requestData)); }
Original Bug Report
Potential Cross-thread UAF in MediaDrmBridge on Android via Data Race
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the security team.
Overview: A potential Use-After-Free (UAF) vulnerability exists in MediaDrmBridge on Android due to missing synchronization on the mNativeMediaDrmBridge field. Mismatched threading between Chrome’s dedicated media thread and Android’s MediaDrm callbacks allows the GPU main thread to invoke JNI methods using a dangling pointer, potentially leading to Remote Code Execution (RCE) in the GPU process.
Affected files:
media/base/android/java/src/org/chromium/media/MediaDrmBridge.javamedia/base/android/media_drm_bridge.ccmedia/base/android/media_drm_bridge.h
Estimated timestamp from git blame: 2024-05-28
Description
A potential Use-After-Free (UAF) race condition exists in the Android MediaDrmBridge implementation. On Android, Chrome’s GPU process creates the MediaDrmBridge on a dedicated base::ThreadPool task runner. Because this native thread lacks a Java Looper, Android’s MediaDrm listener APIs (e.g., OnKeyStatusChangeListener) fall back to dispatching callbacks on the application’s Main Looper, which corresponds to the GPU Main Thread.
When the DRM session is destroyed, the C++ MediaDrmBridge is deleted on the ThreadPool thread, which calls into Java’s MediaDrmBridge.destroy() to set mNativeMediaDrmBridge = 0. However, the mNativeMediaDrmBridge field is a primitive long and is accessed without volatile or synchronized modifiers.
If a DRM callback fires concurrently on the GPU Main Thread, it reads the stale, non-zero mNativeMediaDrmBridge pointer. Due to the lack of synchronization, this introduces a Time-of-Check to Time-of-Use (TOCTOU) and data visibility race. The GPU Main Thread then passes the dangling pointer back to C++ via JNI. The C++ code (e.g., MediaDrmBridge::OnSessionKeysChange) attempts to access members of the freed object (like task_runner_->PostTask), leading to a virtual call on a freed object.
Potential Attacker Steps
Note: Our tooling agent does not have the ability to run code, so these are suggested steps based on static analysis.
- Setup: An attacker compromises a Renderer process and sends a Mojo IPC request to the GPU process to create a Widevine DRM session.
- Heap Grooming: The attacker uses GPU IPCs (e.g., WebGL/WebGPU) to groom the GPU process heap, preparing to reclaim the memory block of the
MediaDrmBridgeonce freed. - Trigger: The attacker triggers an asynchronous DRM event (such as a key status change) via the DRM session.
- Race: Concurrently, the attacker drops the Mojo connection to the
MediaService, initiating the teardown of theMediaDrmBridgeon the ThreadPool thread. - UAF Execution: The ThreadPool thread frees the C++ object and clears the Java pointer. Simultaneously, the GPU Main Thread processes the DRM callback, reads the stale native pointer due to the missing synchronization, and calls into C++.
- RCE: The JNI invocation lands in
MediaDrmBridge::OnSessionKeysChangeon the freed object. The attacker-controlled reclaimed memory allows them to hijack the virtual method calltask_runner_->PostTask(), achieving Remote Code Execution in the GPU process.
Suggested Fix
- Java-Side Synchronization: Implement
synchronizedblocks around all accesses tomNativeMediaDrmBridge(similar to howMediaCodecBridgehandlesmNativeMediaCodecBridge). This ensures the pointer check and JNI invocation are atomic relative todestroy(). - C++ Side WeakPtr: Consider changing the JNI architecture to pass an ID or handle that can be safely resolved on the C++ side using a thread-safe mechanism, or pass a raw pointer to an object that strictly vends
base::WeakPtrs, rather than passing a raw pointer to theMediaDrmBridgeitself.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.