Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Messages
DescriptionUse after free in Messages
ComponentMessages
Bug ClassUAF
Tracker502072755
Fix commit8e80975514e4 (chromium/src) +95/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
source_set
components/messages/android/BUILD.gn
modified
message_enqueued_
components/messages/android/message_wrapper.cc
modified
is_in_queue_
components/messages/android/message_wrapper.cc
modified
if
components/messages/android/message_wrapper.cc
modified
MessageWrapperTest
components/messages/android/message_wrapper_unittest.cc
modified
TEST_F
components/messages/android/message_wrapper_unittest.cc
modified

Files Changed

  • components/BUILD.gn
  • components/messages/android/BUILD.gn
  • components/messages/android/java/src/org/chromium/components/messages/MessageWrapper.java
  • components/messages/android/message_dispatcher_bridge.cc
  • components/messages/android/message_wrapper.cc
  • components/messages/android/message_wrapper.h
  • components/messages/android/message_wrapper_unittest.cc
From 8e80975514e477664215843ab8e076c64d9dd994 Mon Sep 17 00:00:00 2001
From: Lijin Shen <lazzzis@google.com>
Date: Fri, 17 Apr 2026 12:29:59 -0700
Subject: [PATCH] [Messages] Clear native pointer in MessageWrapper destructor

This CL adds a call to `clearNativePtr` in the destructor of
`MessageWrapper` on the C++ side. This ensures that when the C++ object
is destroyed, the associated Java object has its native pointer set to
0.

Bug: 502072755
Change-Id: Ie2e7dda6352c0b3fd93bfabdd7653422769d8455
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7762195
Reviewed-by: Theresa Wellington <twellington@chromium.org>
Commit-Queue: Lijin Shen <lazzzis@google.com>
Cr-Commit-Position: refs/heads/main@{#1616775}
---

diff --git a/components/BUILD.gn b/components/BUILD.gn
index 3731013e..34f7823d 100644
--- a/components/BUILD.gn
+++ b/components/BUILD.gn
@@ -705,6 +705,7 @@
       "//components/media_router/browser/android:test_support_java",
       "//components/messages/android:java",
       "//components/messages/android:unit_tests",
+      "//components/messages/android/test:test_support_java",
       "//components/metrics:metrics_java",
       "//components/offline_items_collection/core:native_java_unittests_java",
       "//components/one_time_tokens/android/backend/sms:java",
diff --git a/components/messages/android/BUILD.gn b/components/messages/android/BUILD.gn
index ddd00cf..5b350bf 100644
--- a/components/messages/android/BUILD.gn
+++ b/components/messages/android/BUILD.gn
@@ -115,11 +115,18 @@
 
 source_set("unit_tests") {
   testonly = true
-  sources = [ "message_dispatcher_bridge_unittest.cc" ]
+  sources = [
+    "message_dispatcher_bridge_unittest.cc",
+    "message_wrapper_unittest.cc",
+  ]
 
   deps = [
     ":android",
+    ":jni_headers",
+    ":test_support",
     "//base",
+    "//base/test:test_support",
+    "//components/messages/android/test:test_support_cpp",
     "//testing/gtest",
   ]
 }
diff --git a/components/messages/android/java/src/org/chromium/components/messages/MessageWrapper.java b/components/messages/android/java/src/org/chromium/components/messages/MessageWrapper.java
index 7df20583..11cd35e 100644
--- a/components/messages/android/java/src/org/chromium/components/messages/MessageWrapper.java
+++ b/components/messages/android/java/src/org/chromium/components/messages/MessageWrapper.java
@@ -231,6 +231,10 @@
         mNativeMessageWrapper = 0;
     }
 
+    long getNativePtrForTesting() {
+        return mNativeMessageWrapper;
+    }
+
     @CalledByNative
     Bitmap getIconBitmap() {
         Drawable drawable = mMessageProperties.get(MessageBannerProperties.ICON);
diff --git a/components/messages/android/message_dispatcher_bridge.cc b/components/messages/android/message_dispatcher_bridge.cc
index 33d8490..af28a66 100644
--- a/components/messages/android/message_dispatcher_bridge.cc
+++ b/components/messages/android/message_dispatcher_bridge.cc
@@ -8,6 +8,7 @@
 
 #include "base/android/jni_android.h"
 #include "base/android/scoped_java_ref.h"
+#include "base/check.h"
 #include "base/no_destructor.h"
 #include "content/public/browser/web_contents.h"
 
@@ -43,6 +44,7 @@
                                              content::WebContents* web_contents,
                                              MessageScopeType scope_type,
                                              MessagePriority priority) {
+  CHECK(!message->is_in_queue());
   JNIEnv* env = base::android::AttachCurrentThread();
   if (Java_MessageDispatcherBridge_enqueueMessage(
           env, message->GetJavaMessageWrapper(),
@@ -59,6 +61,7 @@
     MessageWrapper* message,
     ui::WindowAndroid* window_android,
     MessagePriority priority) {
+  CHECK(!message->is_in_queue());
   JNIEnv* env = base::android::AttachCurrentThread();
   if (Java_MessageDispatcherBridge_enqueueWindowScopedMessage(
           env, message->GetJavaMessageWrapper(),
diff --git a/components/messages/android/message_wrapper.cc b/components/messages/android/message_wrapper.cc
index 33ba9c2..cba2166 100644
--- a/components/messages/android/message_wrapper.cc
+++ b/components/messages/android/message_wrapper.cc
@@ -25,7 +25,7 @@
                                DismissCallback dismiss_callback)
     : action_callback_(std::move(action_callback)),
       dismiss_callback_(std::move(dismiss_callback)),
-      message_enqueued_(false) {
+      is_in_queue_(false) {
   JNIEnv* env = base::android::AttachCurrentThread();
   java_message_wrapper_ =
       Java_MessageWrapper_create(env, reinterpret_cast<int64_t>(this),
@@ -33,7 +33,14 @@
 }
 
 MessageWrapper::~MessageWrapper() {
-  CHECK(!message_enqueued_);
+  CHECK(!is_in_queue_);
+  if (java_message_wrapper_) {
+    // Clear the native pointer on the Java side in case the Java object
+    // outlives the C++ object, such as message_wrapper is created but never
+    // enqueued.
+    JNIEnv* env = base::android::AttachCurrentThread();
+    Java_MessageWrapper_clearNativePtr(env, java_message_wrapper_);
+  }
 }
 
 std::u16string MessageWrapper::GetTitle() {
@@ -259,7 +266,7 @@
 
 void MessageWrapper::HandleDismissCallback(JNIEnv* env, int dismiss_reason) {
   // Make sure message dismissed callback is called exactly once.
-  message_enqueued_ = false;
+  is_in_queue_ = false;
   Java_MessageWrapper_clearNativePtr(env, java_message_wrapper_);
   if (!dismiss_callback_.is_null())
     std::move(dismiss_callback_)
@@ -276,7 +283,7 @@
 
 void MessageWrapper::SetMessageEnqueued(
     const base::android::JavaRef<jobject>& java_window_android) {
-  message_enqueued_ = true;
+  is_in_queue_ = true;
   java_window_android_ = java_window_android;
   JNIEnv* env = base::android::AttachCurrentThread();
   Java_MessageWrapper_initializeSecondaryMenu(
diff --git a/components/messages/android/message_wrapper.h b/components/messages/android/message_wrapper.h
index 438002b..b0823fc 100644
--- a/components/messages/android/message_wrapper.h
+++ b/components/messages/android/message_wrapper.h
@@ -116,6 +116,8 @@
     return java_window_android_;
   }
 
+  bool is_in_queue() const { return is_in_queue_; }
+
   const SkBitmap GetIconBitmap();
 
  private:
@@ -125,7 +127,7 @@
   SecondaryMenuItemSelectedCallback secondary_menu_item_selected_callback_;
   DismissCallback dismiss_callback_;
   // True if message is in queue.
-  bool message_enqueued_;
+  bool is_in_queue_;
   base::android::ScopedJavaGlobalRef<jobject> java_window_android_;
 
   SecondaryMenuMaxSize secondary_menu_max_size_ = SecondaryMenuMaxSize::SMALL;
diff --git a/components/messages/android/message_wrapper_unittest.cc b/components/messages/android/message_wrapper_unittest.cc
new file mode 100644
index 0000000..7cb6c5e
--- /dev/null
+++ b/components/messages/android/message_wrapper_unittest.cc
@@ -0,0 +1,43 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "components/messages/android/message_wrapper.h"
+
+#include <memory>
+
+#include "base/android/jni_android.h"
+#include "base/android/scoped_java_ref.h"
+#include "base/test/task_environment.h"
+#include "components/messages/android/test/messages_test_helper.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace messages {
+
+class MessageWrapperTest : public ::testing::Test {
+ protected:
+  base::test::SingleThreadTaskEnvironment task_environment_;
+  MessagesTestHelper helper_;
+};
+
+TEST_F(MessageWrapperTest, ClearsJavaPointerOnDestruction) {
+  // Create the MessageWrapper. MessageIdentifier::TEST_MESSAGE is used.
+  auto message_wrapper =
+      std::make_unique<MessageWrapper>(MessageIdentifier::TEST_MESSAGE);
+
+  // Keep a reference to the Java object before destroying the C++ object.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/messages/android/message_wrapper_unittest.cc b/components/messages/android/message_wrapper_unittest.cc
new file mode 100644
index 0000000..7cb6c5e
--- /dev/null
+++ b/components/messages/android/message_wrapper_unittest.cc
@@ -0,0 +1,43 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "components/messages/android/message_wrapper.h"
+
+#include <memory>
+
+#include "base/android/jni_android.h"
+#include "base/android/scoped_java_ref.h"
+#include "base/test/task_environment.h"
+#include "components/messages/android/test/messages_test_helper.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace messages {
+
+class MessageWrapperTest : public ::testing::Test {
+ protected:
+  base::test::SingleThreadTaskEnvironment task_environment_;
+  MessagesTestHelper helper_;
+};
+
+TEST_F(MessageWrapperTest, ClearsJavaPointerOnDestruction) {
+  // Create the MessageWrapper. MessageIdentifier::TEST_MESSAGE is used.
+  auto message_wrapper =
+      std::make_unique<MessageWrapper>(MessageIdentifier::TEST_MESSAGE);
+
+  // Keep a reference to the Java object before destroying the C++ object.
+  base::android::ScopedJavaGlobalRef<jobject> java_object;
+  java_object.Reset(base::android::AttachCurrentThread(),
+                    message_wrapper->GetJavaMessageWrapper());
+
+  // Verify it is not 0 initially.
+  ASSERT_NE(0, helper_.GetNativePtr(java_object));
+
+  // Destroy the C++ object.
+  message_wrapper.reset();
+
+  // Verify that the Java pointer was cleared (set to 0).
+  EXPECT_EQ(0, helper_.GetNativePtr(java_object));
+}
+
+}  // namespace messages
diff --git a/components/messages/android/test/java/src/org/chromium/components/messages/MessagesTestHelper.java b/components/messages/android/test/java/src/org/chromium/components/messages/MessagesTestHelper.java
index 66a5e45..f843100 100644
--- a/components/messages/android/test/java/src/org/chromium/components/messages/MessagesTestHelper.java
+++ b/components/messages/android/test/java/src/org/chromium/components/messages/MessagesTestHelper.java
@@ -5,6 +5,7 @@
 package org.chromium.components.messages;
 
 import org.jni_zero.CalledByNative;
+import org.jni_zero.CalledByNativeForTesting;
 import org.jni_zero.JNINamespace;
 import org.jni_zero.NativeMethods;
 
@@ -138,11 +139,17 @@
         return messageStates.get(index).handler.getMessageIdentifier();
     }
 
+    @CalledByNativeForTesting
+    public static long getNativePtr(MessageWrapper messageWrapper) {
+        return messageWrapper.getNativePtrForTesting();
+    }
+
     /**
      * Get the property model of a message.
+     *
      * @param messageStateHandler The {@link MessageStateHandler} of an active message.
      * @return The {@link PropertyModel} of a message if applicable. Currently supported
-     *         implementations include {@link SingleActionMessage}.
+     *     implementations include {@link SingleActionMessage}.
      */
     public static PropertyModel getCurrentMessage(MessageStateHandler messageStateHandler) {
         assert messageStateHandler != null;
diff --git a/components/messages/android/test/messages_test_helper.cc b/components/messages/android/test/messages_test_helper.cc
index 52969f3..991f41e 100644
--- a/components/messages/android/test/messages_test_helper.cc
+++ b/components/messages/android/test/messages_test_helper.cc
@@ -4,6 +4,11 @@
 
 #include "components/messages/android/test/messages_test_helper.h"
 
+#include <cstdint>
+
+#include "base/android/jni_android.h"
+#include "base/functional/callback.h"
+
 // Must come after all headers that specialize FromJniType() / ToJniType().
 #include "components/messages/android/test/jni_headers/MessagesTestHelper_jni.h"
 
@@ -16,8 +21,7 @@
 
 MessagesTestHelper::~MessagesTestHelper() = default;
 
-int messages::MessagesTestHelper::GetMessageCount(
-    ui::WindowAndroid* window_android) {
+int MessagesTestHelper::GetMessageCount(ui::WindowAndroid* window_android) {
   JNIEnv* env = jni_zero::AttachCurrentThread();
   return Java_MessagesTestHelper_getMessageCount(
       env, window_android->GetJavaObject());
@@ -30,6 +34,12 @@
       env, window_android->GetJavaObject(), index);
 }
 
+int64_t MessagesTestHelper::GetNativePtr(
+    const base::android::JavaRef<jobject>& message_wrapper) {
+  JNIEnv* env = jni_zero::AttachCurrentThread();
+  return Java_MessagesTestHelper_getNativePtr(env, message_wrapper);
+}
+
 void MessagesTestHelper::AttachTestMessageDispatcherForTesting(
     ui::WindowAndroid* window_android) {
   JNIEnv* env = jni_zero::AttachCurrentThread();
diff --git a/components/messages/android/test/messages_test_helper.h b/components/messages/android/test/messages_test_helper.h
index 8057e5e..94c735de 100644
--- a/components/messages/android/test/messages_test_helper.h
+++ b/components/messages/android/test/messages_test_helper.h
@@ -7,6 +7,7 @@
 
 #include <jni.h>
 
+#include "base/android/scoped_java_ref.h"
 #include "base/functional/callback_forward.h"
 #include "ui/android/window_android.h"
 
@@ -24,6 +25,7 @@
 
   int GetMessageCount(ui::WindowAndroid* window_android);
   int GetMessageIdentifier(ui::WindowAndroid* window_android, int index);
+  int64_t GetNativePtr(const base::android::JavaRef<jobject>& message_wrapper);
 
   // Attach a test-only simplified message dispatcher to the window android.
   // This is required to listen to events like message enqueued.
Loading diff…

Original Bug Report

reported by vm...@google.com

Use-After-Free in Android HaTS MessageWrapper via bypass of Java MessageDispatcher

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 Chrome Security team.

Overview: A Use-After-Free vulnerability exists in the Android Happiness Tracking Survey (HaTS) component. If a WebContents is destroyed while a survey is pending, the native MessageWrapper object is freed without clearing its Java counterpart’s native pointer, leading to a highly exploitable UAF when the survey is eventually interacted with.

Affected files:

  • chrome/browser/ui/android/hats/hats_service_android.cc
  • components/messages/android/message_wrapper.cc
  • chrome/browser/ui/android/hats/java/src/org/chromium/chrome/browser/ui/hats/MessageSurveyUiDelegate.java
  • components/messages/android/java/src/org/chromium/components/messages/MessageWrapper.java

Estimated timestamp from git blame: 2023-09-08

Summary

A precise Use-After-Free (UAF) vulnerability exists in the Android Happiness Tracking Survey (HaTS) component in the browser process.

When a survey is launched (e.g., following a permission prompt), a native C++ messages::MessageWrapper object is created alongside a corresponding Java peer. If the WebContents that triggered the survey is destroyed while the survey payload is asynchronously downloading, the DelayedSurveyTask correctly detects this and destroys the C++ MessageWrapper.

However, because the HaTS Java code enqueues survey messages by directly bypassing the C++ MessageDispatcherBridge, the message_enqueued_ safety flag remains false. This causes the C++ ~MessageWrapper() destructor to skip its safety CHECK and free the memory without clearing the dangling native pointer in the Java peer. When the survey banner is eventually displayed and the user interacts with it (or it auto-dismisses), the Java code invokes JNI callbacks using the dangling pointer, resulting in reliable Remote Code Execution (RCE).

Technical Details

  1. Survey Creation: HatsServiceAndroid::LaunchDelayedSurveyForWebContents creates a DelayedSurveyTask. This task allocates a messages::MessageWrapper (C++) and invokes JNI to create a matching Java MessageWrapper object. The C++ this pointer is cast to a raw long and stored in the Java object’s mNativeMessageWrapper field.

  2. Asynchronous Download: The Java SurveyClientImpl initiates an asynchronous download for the survey payload via SurveyController.downloadSurvey(). During this time, the Java objects are kept alive by callbacks.

  3. WebContents Destruction: If the WebContents is destroyed (e.g., via script calling popup.close()), the DelayedSurveyTask (a WebContentsObserver) detects this and removes itself via HatsServiceAndroid::RemoveTask(). This destroys the C++ MessageWrapper.

  4. Bypass of Safety Checks:

    • The C++ ~MessageWrapper() destructor asserts CHECK(!message_enqueued_);.
    • Normally, MessageDispatcherBridge in C++ sets this flag to true.
    • However, MessageSurveyUiDelegate.java explicitly calls mMessageDispatcher.enqueueWindowScopedMessage(...) entirely on the Java side. The C++ bridge is bypassed, so message_enqueued_ remains false.
    • The C++ destructor completes without crashing.
  5. Dangling Java Pointer: The C++ destructor frees the object memory, but fails to call Java_MessageWrapper_clearNativePtr(). The Java MessageWrapper.mNativeMessageWrapper remains populated with a pointer to freed memory.

  6. UAF and MiraclePtr Bypass: MiraclePtr provides no protection here because the pointer was passed across JNI as a raw long; there is no raw_ptr to maintain the quarantine reference count. The memory is fully returned to the allocator.

  7. Exploitation: An attacker can spray the browser heap to reallocate this freed chunk. Once the survey download completes, the banner is displayed. When the user clicks the banner, Java executes MessageWrapperJni.get().handleActionClick(mNativeMessageWrapper). This enters the C++ method MessageWrapper::HandleActionClick, which invokes std::move(action_callback_).Run(). By controlling the reallocated memory, the attacker controls the base::OnceClosure and hijacks the vtable, achieving sandbox escape and full RCE in the browser process.

Potential Reproduction Steps (Conceptual)

  1. Host an attacker page that opens a popup window.
  2. In the popup, trigger a feature that probabilistically qualifies for a HaTS survey (e.g., navigator.geolocation.getCurrentPosition()).
  3. The user accepts or denies the prompt, triggering the background survey download.
  4. The attacker script immediately calls popup.close() to destroy the WebContents before the download completes.
  5. The attacker script performs a targeted heap spray in the browser process.
  6. The survey download completes, and the HaTS banner appears on the original opener tab.
  7. The user clicks the banner, triggering the UAF and executing the attacker’s payload.

Suggested Fix

The ~MessageWrapper() destructor must safely inform the Java peer that the native object is being destroyed, regardless of whether the message was enqueued or not. Add Java_MessageWrapper_clearNativePtr(env, java_message_wrapper_); inside the ~MessageWrapper() destructor to ensure the Java-side dangling pointer is always zeroed out upon destruction.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


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