CVE-2026-84353
Overview
Background
- Native callback handle
- the Android Java layer stores a raw pointer to a heap-allocated native callback (
ResultCallback,base::OnceClosure, etc.) as anint64_tfield such asmExitCallback, 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 viaonFlowFinished(), and the delegate is later torn down viadestroy(); 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_ptrfrom theint64_thandle via helpers likeconversion::GetNativeExitCallbackFromJava, then eitherRun()it orreset()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.
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
- Establish the collaboration flow
The victim participates in a Shared Tab Groups collaboration (e.g. a
FlowType.JOINflow), andprepareFlowUIstores the native exit-callback pointer intomExitCallback. - Finish the flow, freeing the callback
onFlowFinished()(inferred)callsdeleteExitCallback(mExitCallback), whose C++ side (@@ -51hunk) doescallback_ptr.reset()and frees the nativebase::OnceClosure, but pre-patch leavesmExitCallbackpointing at the freed object. - Trigger teardown on the stale handle
destroy()(inferred)reads the still-populatedmExitCallbackintotempCallbackand unconditionally callsrunExitCallback(tempCallback). - Use-after-free in native code
The C++ entry point (
@@ -43/46hunk) reconstructs astd::unique_ptrfrom the freed handle and executesstd::move(*callback_ptr).Run(), dereferencing and invoking freed memory. -
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
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
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/collaboration/android/collaboration_controller_delegate_android.cc |
modified | |
ifchrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImpl.java |
modified |
Files Changed
chrome/browser/collaboration/android/collaboration_controller_delegate_android.ccchrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImpl.javachrome/browser/collaboration/android/java/src/org/chromium/chrome/browser/collaboration/CollaborationControllerDelegateImplUnitTest.java
Audit Directions
- Consume-and-clear invariant on raw native handlesAudit 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 to0, since any surviving stale value can be re-dispatched by a later lifecycle method. - Mutually exclusive lifecycle exitsWherever two teardown paths (
onFlowFinished()vsdestroy(), 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 boundariesCheck native JNI entry points that reconstruct owning smart pointers from an
int64_tfor a missing null check beforeRun()/deref/reset(), matching theif (!callback_ptr) { return; }pattern added here.
Patch
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;