Critical watch CVSS 9.6 chrome UAF 🔧 Commit mapped

Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Shared Tab Groups
DescriptionUse after free in Shared Tab Groups
ComponentShared Tab Groups
Sub-componentCollaboration
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.
Tracker522307103
Fix commit9731d1143de6 (chromium/src) +39/-3
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-06-10 (per vendor advisory)
Fix landed2026-06-24 (public git)
Disclosed2026-09-01
Patch gap69 days — fix public in source before the advisory

Background

Native callback handle
the Android Java layer stores a raw pointer to a heap-allocated native callback (ResultCallback, base::OnceClosure, etc.) as an int64_t field such as mExitCallback, handing it back to C++ across JNI to run or delete.
Collaboration flow lifecycle
a Shared Tab Groups flow is set up via prepareFlowUI, may end via onFlowFinished(), and the delegate is later torn down via destroy(); these transitions decide whether the native exit callback is run or freed.
`std::unique_ptr` reconstruction
the C++ JNI entry points rebuild an owning std::unique_ptr from the int64_t handle via helpers like conversion::GetNativeExitCallbackFromJava, then either Run() it or reset() it, freeing the underlying object.

Root Cause Analysis

In CollaborationControllerDelegateImpl.java, the exit-callback handle mExitCallback was consumed by two lifecycle methods without a consistent “clear-after-use” invariant. On the flow-finished path (the hunk at @@ -855, onFlowFinished() (inferred)), the old code called deleteExitCallback(mExitCallback) — which frees the native base::OnceClosure — but never reset mExitCallback to 0, leaving a stale pointer in the field. When teardown then ran (the hunk at @@ -874, destroy() (inferred)), the old code unconditionally executed runExitCallback(tempCallback) using that already-freed handle, causing the native side to reconstruct a std::unique_ptr over freed memory and invoke std::move(*callback_ptr).Run() — a use-after-free.

The fix restores the invariant: the delete path now copies to tempCallback, sets mExitCallback = 0, and only deletes when non-zero; the run path now guards if (tempCallback != 0) before calling runExitCallback, so a cleared handle is never dispatched. As defense in depth, each C++ JNI entry point in collaboration_controller_delegate_android.cc adds an early if (!callback_ptr) { return; } so a null/0 handle is dropped instead of dereferenced.

Key insight
The core mistake was freeing the native exit callback in the flow-finished path while leaving its stale pointer live in mExitCallback, so a later destroy() re-dispatched the freed handle; the fix zeroes the field on every consume and guards the run against a zero handle, making the two lifecycle exits mutually exclusive.

Attack Path

  1. Establish the collaboration flow The victim participates in a Shared Tab Groups collaboration (e.g. a FlowType.JOIN flow), and prepareFlowUI stores the native exit-callback pointer into mExitCallback.
  2. Finish the flow, freeing the callback onFlowFinished() (inferred) calls deleteExitCallback(mExitCallback), whose C++ side (@@ -51 hunk) does callback_ptr.reset() and frees the native base::OnceClosure, but pre-patch leaves mExitCallback pointing at the freed object.
  3. Trigger teardown on the stale handle destroy() (inferred) reads the still-populated mExitCallback into tempCallback and unconditionally calls runExitCallback(tempCallback).
  4. Use-after-free in native code The C++ entry point (@@ -43/46 hunk) reconstructs a std::unique_ptr from the freed handle and executes std::move(*callback_ptr).Run(), dereferencing and invoking freed memory.
  5. Downstream exploitation (inferred) — Controlling the contents of the freed allocation before step 4 could turn the freed-closure invocation into attacker-influenced control flow; the diff does not demonstrate any such primitive.

Impact Assessment

An attacker gains a use-after-free of a native callback object in the Chrome browser process on Android, reached through the normal finish-then-destroy sequence of a Shared Tab Groups collaboration flow. The precondition is driving the delegate through onFlowFinished() and then destroy() while the exit callback is outstanding, so that a freed native handle is re-dispatched. The vendor advisory rates this critical severity and does not report in-the-wild exploitation; NVD scores it CVSS 9.6. The advisory metadata states only “Use after free in Shared Tab Groups” and does not report exploited-in-the-wild status or a specific severity.

Changed Functions

FunctionChangeNotes
if
chrome/browser/collaboration/android/collaboration_controller_delegate_android.cc
modified
if
chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImpl.java
modified

Files Changed

  • chrome/browser/collaboration/android/collaboration_controller_delegate_android.cc
  • chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImpl.java
  • chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImplUnitTest.java

Audit Directions

  • Consume-and-clear invariant on raw native handles
    Audit every Java field holding a native pointer as int64_t (e.g. result/exit/group-token callbacks) for paths that free or run the handle without immediately setting the field to 0, since any surviving stale value can be re-dispatched by a later lifecycle method.
  • Mutually exclusive lifecycle exits
    Wherever two teardown paths (onFlowFinished() vs destroy(), cancel vs complete) can both fire, verify each consumes the callback exactly once and that later paths guard on a zero/null sentinel before touching it.
  • Null/zero handle validation at JNI boundaries
    Check native JNI entry points that reconstruct owning smart pointers from an int64_t for a missing null check before Run()/deref/reset(), matching the if (!callback_ptr) { return; } pattern added here.
From 9731d1143de694cccb2a8d15616661ea741156d5 Mon Sep 17 00:00:00 2001
From: Hailey Wang <haileywang@google.com>
Date: Wed, 24 Jun 2026 13:07:11 -0700
Subject: [PATCH] [Collaboration] Fix java callback cleanup after flow finished

Bug: 522307103
Change-Id: Ifc7936c51b9c2e9d94934be43675fa41cdbeb294
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7926580
Commit-Queue: Hailey Wang <haileywang@google.com>
Reviewed-by: Siddhartha S <ssid@chromium.org>
Reviewed-by: Ritika Gupta <ritikagup@google.com>
Cr-Commit-Position: refs/heads/main@{#1651927}
---
diff --git a/chrome/browser/collaboration/android/collaboration_controller_delegate_android.cc b/chrome/browser/collaboration/android/collaboration_controller_delegate_android.cc
index f6391cb..1cbe88a4 100644
--- a/chrome/browser/collaboration/android/collaboration_controller_delegate_android.cc
+++ b/chrome/browser/collaboration/android/collaboration_controller_delegate_android.cc
@@ -33,6 +33,9 @@
     int64_t callback) {
   std::unique_ptr<ResultCallback> callback_ptr =
       conversion::GetNativeResultCallbackFromJava(callback);
+  if (!callback_ptr) {
+    return;
+  }
   CollaborationControllerDelegate::Outcome outcome =
       static_cast<CollaborationControllerDelegate::Outcome>(joutcome);
   std::move(*callback_ptr).Run(outcome);
@@ -43,6 +46,9 @@
     int64_t callback) {
   std::unique_ptr<base::OnceClosure> callback_ptr =
       conversion::GetNativeExitCallbackFromJava(callback);
+  if (!callback_ptr) {
+    return;
+  }
   std::move(*callback_ptr).Run();
 }
 
@@ -51,6 +57,9 @@
     int64_t callback) {
   std::unique_ptr<base::OnceClosure> callback_ptr =
       conversion::GetNativeExitCallbackFromJava(callback);
+  if (!callback_ptr) {
+    return;
+  }
   callback_ptr.reset();
 }
 
@@ -63,6 +72,9 @@
     int64_t callback) {
   std::unique_ptr<ResultWithGroupTokenCallback> callback_ptr =
       conversion::GetNativeResultWithGroupTokenCallbackFromJava(callback);
+  if (!callback_ptr) {
+    return;
+  }
   CollaborationControllerDelegate::Outcome outcome =
       static_cast<CollaborationControllerDelegate::Outcome>(joutcome);
 
diff --git a/chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImpl.java b/chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImpl.java
index dd9e65b..756f55f 100644
--- a/chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImpl.java
+++ b/chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImpl.java
@@ -855,8 +855,10 @@
         if (mFeatureEngagementLock != null) {
             mFeatureEngagementLock.release();
         }
-        if (mExitCallback != 0) {
-            CollaborationControllerDelegateImplJni.get().deleteExitCallback(mExitCallback);
+        long tempCallback = mExitCallback;
+        mExitCallback = 0;
+        if (tempCallback != 0) {
+            CollaborationControllerDelegateImplJni.get().deleteExitCallback(tempCallback);
         }
     }
 
@@ -874,7 +876,9 @@
         mThreadChecker.assertOnValidThread();
         long tempCallback = mExitCallback;
         mExitCallback = 0;
-        CollaborationControllerDelegateImplJni.get().runExitCallback(tempCallback);
+        if (tempCallback != 0) {
+            CollaborationControllerDelegateImplJni.get().runExitCallback(tempCallback);
+        }
     }
 
     @SuppressWarnings("NullAway")
diff --git a/chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImplUnitTest.java b/chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImplUnitTest.java
index 89a0b99..55ea56e 100644
--- a/chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImplUnitTest.java
+++ b/chrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImplUnitTest.java
@@ -327,6 +327,26 @@
     }
 
     @Test
+    public void testDestroyAfterFlowFinishedDoesNotRunExitCallback() {
+        createDelegate(FlowType.JOIN);
+        long prepareResultCallback = 1;
+        long exitCallback = 2;
+
+        mCollaborationControllerDelegateImpl.prepareFlowUI(exitCallback, prepareResultCallback);
+
+        // Simulate flow finished, which deletes exitCallback.
+        mCollaborationControllerDelegateImpl.onFlowFinished();
+
+        // Simulate subsequent destroy.
+        mCollaborationControllerDelegateImpl.destroy();
+
+        // Delete should have been called, but runExitCallback should never be called.
+        verify(mCollaborationControllerDelegateImplNativeMock).deleteExitCallback(eq(exitCallback));
+        verify(mCollaborationControllerDelegateImplNativeMock, never())
+                .runExitCallback(eq(exitCallback));
+    }
+
+    @Test
     public void testPromoteTabGroup() {
         createDelegate(FlowType.JOIN);
         long resultCallback = 1;
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.