High watch CVSS 8.3 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Media
DescriptionUse after free in Media
ComponentMedia
Sub-componentCdm
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker499023054
Fix commit5625d2ec9373 (chromium/src) +100/-49
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-02 (per vendor advisory)
Fix landed2026-04-16 (public git)
Disclosed2026-04-28
Patch gap12 days — fix public in source before the advisory

Background

`MediaDrmBridge`
a Java object in media/base/android that wraps Android’s MediaDrm DRM API and holds a pointer (mNativeMediaDrmBridge) back to the C++ media::MediaDrmBridge it calls into over JNI.
JNI callback
a method such as onPromiseResolved, onSessionMessage, or onProvisioningComplete invoked (often from a background/MediaDrm event thread) that forwards results to the native object via MediaDrmBridgeJni.get().
`INVALID_NATIVE_MEDIA_DRM_BRIDGE`
the sentinel value assigned to mNativeMediaDrmBridge by destroy() 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.

Key insight
The core mistake was validating and using the native pointer without holding any lock across the call, so destruction on another thread could free the native object mid-callback; the fix serializes callbacks and destruction through a lock plus a pending-call counter so destroy() cannot proceed while a JNI call is in flight.

Attack Path

  1. Set up DRM playback Web content drives an EME/DRM session so that MediaDrmBridge and its native peer exist and MediaDrm event callbacks (session messages, promise resolutions, provisioning) are being delivered on background threads.
  2. Race a callback against teardown Arrange for a JNI callback (e.g. onSessionMessage or onPromiseResolved) to be in flight at the same moment the pipeline tears the bridge down, invoking destroy().
  3. Win the TOCTOU window The callback passes the old isNativeMediaDrmBridgeValid() check, then destroy() sets the pointer invalid and the native media::MediaDrmBridge is freed before the callback issues its JNI call.
  4. 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

An attacker who can script DRM/EME playback can trigger a use-after-free of the native 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

FunctionChangeNotes
if
media/base/android/java/src/org/chromium/media/MediaDrmBridge.java
modified
synchronized
media/base/android/java/src/org/chromium/media/MediaDrmBridge.java
modified
while
media/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 checks
    Audit other @CalledByNative Java 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 work
    Look 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 delivery
    Review 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.
From 5625d2ec93730e93927ffad958fc3a0f90073218 Mon Sep 17 00:00:00 2001
From: Feras Aldahlawi <frs@chromium.org>
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 <liberato@chromium.org>
Reviewed-by: Vikram Pasupathy <vpasupathy@chromium.org>
Commit-Queue: Feras Aldahlawi <frs@chromium.org>
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));
     }
Loading diff…

Original Bug Report

reported by vm...@google.com

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.java
  • media/base/android/media_drm_bridge.cc
  • media/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.

  1. Setup: An attacker compromises a Renderer process and sends a Mojo IPC request to the GPU process to create a Widevine DRM session.
  2. 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 MediaDrmBridge once freed.
  3. Trigger: The attacker triggers an asynchronous DRM event (such as a key status change) via the DRM session.
  4. Race: Concurrently, the attacker drops the Mojo connection to the MediaService, initiating the teardown of the MediaDrmBridge on the ThreadPool thread.
  5. 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++.
  6. RCE: The JNI invocation lands in MediaDrmBridge::OnSessionKeysChange on the freed object. The attacker-controlled reclaimed memory allows them to hijack the virtual method call task_runner_->PostTask(), achieving Remote Code Execution in the GPU process.

Suggested Fix

  1. Java-Side Synchronization: Implement synchronized blocks around all accesses to mNativeMediaDrmBridge (similar to how MediaCodecBridge handles mNativeMediaCodecBridge). This ensures the pointer check and JNI invocation are atomic relative to destroy().
  2. 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 the MediaDrmBridge itself.

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.

View on issue tracker