Overview

High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Contextual Tasks
DescriptionUse after free in Contextual Tasks
ComponentContextual Tasks
Sub-componentContextual-tasks
Bug ClassUAF
Tracker561660166
Fix commite6f08253f947 (chromium/src) +103/-9
CISA KEVNot listed
Creditedxuanocto1221
Disclosed2026-10-01

Changed Functions

FunctionChangeNotes
if
chrome/browser/contextual_tasks/contextual_tasks_composebox_handler.cc
modified

Files Changed

  • chrome/browser/contextual_tasks/contextual_tasks_composebox_handler.cc
  • chrome/browser/contextual_tasks/contextual_tasks_composebox_handler_browsertest.cc
From e6f08253f947029305f150301d2c7d9bd8b18f07 Mon Sep 17 00:00:00 2001
From: Jason Hu <hujasonx@google.com>
Date: Mon, 21 Sep 2026 16:17:14 -0700
Subject: [PATCH] [contextual-tasks] Fix UAF in ContextualTasksComposeboxHandler callback

When CreateAndSendQueryMessage() triggers recontextualization or URL
uploads, it previously bound its completion callback to `this` using
base::Unretained(this).

However, QueryContextualizer moves this callback into UploadTracker,
which is ref-counted and outlives ContextualTasksComposeboxHandler when
uploads are pending across UI/tab teardown. If the handler is destroyed
while an upload is still in flight, UploadTracker invokes the callback
on controller destruction or upload completion, causing a heap-use-
after-free.

This CL fixes the issue by binding the callback and delegate hooks with
weak_factory_.GetWeakPtr() instead of base::Unretained(this).

Bug: 561660166
Change-Id: Ib56d7bc2e81b03d69740a9685462b61552fc18a1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8435210
Commit-Queue: Jason Hu <hujasonx@google.com>
Reviewed-by: Nihar Majmudar <niharm@google.com>
Cr-Commit-Position: refs/heads/main@{#1702172}
---
diff --git a/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler.cc b/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler.cc
index 80f69e95..c289f39 100644
--- a/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler.cc
+++ b/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler.cc
@@ -494,23 +494,23 @@
   // Kick off the on-submit contextualization flow to upload delayed tabs and
   // recontextualize the active tab.
   recontextualization_pending_count_++;
-  // It is safe to use base::Unretained(this) here because `recontextualizer_`
-  // is owned by `this` and will be destroyed when `this` is destroyed,
-  // cancelling any pending callbacks.
   auto callback = base::BindOnce(
-      [](ContextualTasksComposeboxHandler* handler, std::string query,
-         std::optional<base::Uuid> task_id,
+      [](base::WeakPtr<ContextualTasksComposeboxHandler> handler,
+         std::string query, std::optional<base::Uuid> task_id,
          std::optional<base::UnguessableToken> token, bool voice,
          std::map<std::string, std::string> cgi_params,
          base::WeakPtr<contextual_search::ContextualSearchSessionHandle>
              handle) {
+        if (!handler) {
+          return;
+        }
         // The session handle is accessed via GetContextualSessionHandle(),
         // so we ignore it here.
         handler->ContinueCreateAndSendQueryMessage(query, task_id, token, voice,
                                                    std::move(cgi_params));
       },
-      base::Unretained(this), query, task_id, overlay_token, is_voice_search,
-      additional_cgi_params);
+      weak_factory_.GetWeakPtr(), query, task_id, overlay_token,
+      is_voice_search, additional_cgi_params);
 
   contextual_tasks::QueryContextualizer::ContextualizeParams params;
   params.task_id = task_id;
@@ -519,11 +519,11 @@
   params.auto_suggested_chip_tabs = tabs_to_force_contextualize;
   params.on_ineligible_callback = base::BindRepeating(
       &ContextualTasksComposeboxHandler::OnPageContextIneligible,
-      base::Unretained(this));
+      weak_factory_.GetWeakPtr());
   params.on_processed_callback =
       base::BindRepeating(&ContextualTasksComposeboxHandler::
                               OnTabProcessedForQueryContextualization,
-                          base::Unretained(this));
+                          weak_factory_.GetWeakPtr());
   params.complete_callback = std::move(callback);
   params.enable_smart_tab_selection = IsSmartTabSharingActive();
   recontextualizer_->Contextualize(std::move(params));
diff --git a/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler_browsertest.cc b/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler_browsertest.cc
index c4bd13eb..977c7a5 100644
--- a/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler_browsertest.cc
+++ b/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler_browsertest.cc
@@ -11,6 +11,7 @@
 #include "base/containers/span.h"
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
+#include "base/functional/callback_helpers.h"
 #include "base/memory/raw_ptr.h"
 #include "base/observer_list.h"
 #include "base/run_loop.h"
@@ -59,6 +60,7 @@
 #include "components/keyed_service/content/browser_context_dependency_manager.h"
 #include "components/keyed_service/core/keyed_service.h"
 #include "components/omnibox/common/composebox_features.h"
+#include "components/omnibox/common/omnibox_features.h"
 #include "components/sessions/content/session_tab_helper.h"
 #include "components/sessions/core/session_id.h"
 #include "components/tabs/public/mock_tab_interface.h"
@@ -3602,6 +3604,98 @@
   ASSERT_FALSE(handler_->HasPendingQueryForTesting());
 }
 
+IN_PROC_BROWSER_TEST_F(
+    ContextualTasksComposeboxHandlerTest,
+    NoUafWhenHandlerDestroyedWithPendingRecontextualization) {
+  ASSERT_NE(mock_contextual_tasks_service_ptr_, nullptr)
+      << "Mock controller is NULL!";
+  std::string kQuery = "stashed query";
+  base::Uuid task_id = base::Uuid::GenerateRandomV4();
+  EXPECT_CALL(*mock_ui_, GetTaskId())
+      .WillRepeatedly(
+          testing::ReturnRefOfCopy(std::optional<base::Uuid>(task_id)));
+
+  // Setup context with uploaded tab.
+  contextual_tasks::ContextualTask task(task_id);
+  SessionID session_id = sessions::SessionTabHelper::IdForTab(web_contents());
+  GURL kUrl("about:blank");
+  std::string kTitle = "about:blank";
+
+  contextual_tasks::UrlResource resource(
+      kUrl, contextual_tasks::ResourceType::kWebpage);
+  resource.title = kTitle;
+  resource.tab_id = session_id;
+  task.AddUrlResource(resource);
+
+  auto context =
+      std::make_unique<contextual_tasks::ContextualTaskContext>(task);
+
+  EXPECT_CALL(
+      *mock_contextual_tasks_service_ptr_,
+      GetContextForTask(
+          task_id,
+          testing::Contains(contextual_tasks::ContextualTaskContextSource::
+                                kSubmittedContextDecorator),
+          testing::NotNull(), testing::_))
+      .WillOnce(
+          [&context](
+              const base::Uuid& task_id,
+              const std::set<contextual_tasks::ContextualTaskContextSource>&
+                  sources,
+              std::unique_ptr<contextual_tasks::ContextDecorationParams> params,
+              base::OnceCallback<void(
+                  std::unique_ptr<contextual_tasks::ContextualTaskContext>)>
+                  callback) { std::move(callback).Run(std::move(context)); });
+
+  // Setup FileInfo with expired status.
+  std::vector<raw_ptr<const contextual_search::FileInfo>> file_info_list;
+  contextual_search::FileInfo file_info;
+  file_info.tab_session_id = session_id;
+  file_info.upload_status =
+      contextual_search::ContextUploadStatus::kUploadExpired;
+  file_info.request_id.emplace();
+  file_info.request_id->set_context_id(12345);
+  file_info_list.push_back(&file_info);
+
+  EXPECT_CALL(*mock_controller_, GetFileInfoList())
+      .WillRepeatedly(testing::Return(file_info_list));
+
+  // 1. Capture the GetPageContext callback.
+  MockTabContextualizationController::GetPageContextCallback pending_callback;
+  EXPECT_CALL(*mock_tab_controller_, GetPageContext(testing::_))
+      .WillOnce([&](MockTabContextualizationController::GetPageContextCallback
+                        callback) { pending_callback = std::move(callback); });
+
+  // 2. Call CreateAndSendQueryMessage.
+  handler_->CreateAndSendQueryMessage(kQuery, /*is_voice_search=*/false);
+
+  // 3. Start file upload when recontextualizer completes context fetch.
+  base::UnguessableToken uploaded_token;
+  EXPECT_CALL(*mock_controller_,
+              StartFileUploadFlow(testing::_, testing::_, testing::_))
+      .WillOnce([&](const base::UnguessableToken& file_token,
+                    std::unique_ptr<lens::ContextualInputData> data,
+                    std::optional<lens::ImageEncodingOptions> image_options) {
+        uploaded_token = file_token;
+      });
+
+  auto data = std::make_unique<lens::ContextualInputData>();
+  data->tab_session_id = session_id;
+  data->page_url = GURL("about:blank");
+  data->page_title = "about:blank";
+  data->context_id = 12345;
+  data->is_page_context_eligible = true;
+  std::move(pending_callback).Run(std::move(data));
+
+  // 4. Destroy the handler while upload is still in progress in UploadTracker.
+  handler_.reset();
+
+  // 5. Complete the upload status change. Must safely no-op without UAF.
+  SimulateUploadStatusChanged(
+      uploaded_token, lens::MimeType::kUnknown,
+      contextual_search::ContextUploadStatus::kUploadSuccessful);
+}
+
 IN_PROC_BROWSER_TEST_F(ContextualTasksComposeboxHandlerTest,
                        Recontextualization_TabInvalidatedGracefullyCompletes) {
   ASSERT_NE(mock_contextual_tasks_service_ptr_, nullptr)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler_browsertest.cc b/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler_browsertest.cc
index c4bd13eb..977c7a5 100644
--- a/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler_browsertest.cc
+++ b/chrome/browser/contextual_tasks/contextual_tasks_composebox_handler_browsertest.cc
@@ -11,6 +11,7 @@
 #include "base/containers/span.h"
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
+#include "base/functional/callback_helpers.h"
 #include "base/memory/raw_ptr.h"
 #include "base/observer_list.h"
 #include "base/run_loop.h"
@@ -59,6 +60,7 @@
 #include "components/keyed_service/content/browser_context_dependency_manager.h"
 #include "components/keyed_service/core/keyed_service.h"
 #include "components/omnibox/common/composebox_features.h"
+#include "components/omnibox/common/omnibox_features.h"
 #include "components/sessions/content/session_tab_helper.h"
 #include "components/sessions/core/session_id.h"
 #include "components/tabs/public/mock_tab_interface.h"
@@ -3602,6 +3604,98 @@
   ASSERT_FALSE(handler_->HasPendingQueryForTesting());
 }
 
+IN_PROC_BROWSER_TEST_F(
+    ContextualTasksComposeboxHandlerTest,
+    NoUafWhenHandlerDestroyedWithPendingRecontextualization) {
+  ASSERT_NE(mock_contextual_tasks_service_ptr_, nullptr)
+      << "Mock controller is NULL!";
+  std::string kQuery = "stashed query";
+  base::Uuid task_id = base::Uuid::GenerateRandomV4();
+  EXPECT_CALL(*mock_ui_, GetTaskId())
+      .WillRepeatedly(
+          testing::ReturnRefOfCopy(std::optional<base::Uuid>(task_id)));
+
+  // Setup context with uploaded tab.
+  contextual_tasks::ContextualTask task(task_id);
+  SessionID session_id = sessions::SessionTabHelper::IdForTab(web_contents());
+  GURL kUrl("about:blank");
+  std::string kTitle = "about:blank";
+
+  contextual_tasks::UrlResource resource(
+      kUrl, contextual_tasks::ResourceType::kWebpage);
+  resource.title = kTitle;
+  resource.tab_id = session_id;
+  task.AddUrlResource(resource);
+
+  auto context =
+      std::make_unique<contextual_tasks::ContextualTaskContext>(task);
+
+  EXPECT_CALL(
+      *mock_contextual_tasks_service_ptr_,
+      GetContextForTask(
+          task_id,
+          testing::Contains(contextual_tasks::ContextualTaskContextSource::
+                                kSubmittedContextDecorator),
+          testing::NotNull(), testing::_))
+      .WillOnce(
+          [&context](
+              const base::Uuid& task_id,
+              const std::set<contextual_tasks::ContextualTaskContextSource>&
+                  sources,
+              std::unique_ptr<contextual_tasks::ContextDecorationParams> params,
+              base::OnceCallback<void(
+                  std::unique_ptr<contextual_tasks::ContextualTaskContext>)>
+                  callback) { std::move(callback).Run(std::move(context)); });
+
+  // Setup FileInfo with expired status.
+  std::vector<raw_ptr<const contextual_search::FileInfo>> file_info_list;
+  contextual_search::FileInfo file_info;
+  file_info.tab_session_id = session_id;
+  file_info.upload_status =
+      contextual_search::ContextUploadStatus::kUploadExpired;
+  file_info.request_id.emplace();
+  file_info.request_id->set_context_id(12345);
+  file_info_list.push_back(&file_info);
+
+  EXPECT_CALL(*mock_controller_, GetFileInfoList())
+      .WillRepeatedly(testing::Return(file_info_list));
+
+  // 1. Capture the GetPageContext callback.
+  MockTabContextualizationController::GetPageContextCallback pending_callback;
+  EXPECT_CALL(*mock_tab_controller_, GetPageContext(testing::_))
+      .WillOnce([&](MockTabContextualizationController::GetPageContextCallback
+                        callback) { pending_callback = std::move(callback); });
+
+  // 2. Call CreateAndSendQueryMessage.
+  handler_->CreateAndSendQueryMessage(kQuery, /*is_voice_search=*/false);
+
+  // 3. Start file upload when recontextualizer completes context fetch.
+  base::UnguessableToken uploaded_token;
+  EXPECT_CALL(*mock_controller_,
+              StartFileUploadFlow(testing::_, testing::_, testing::_))
+      .WillOnce([&](const base::UnguessableToken& file_token,
+                    std::unique_ptr<lens::ContextualInputData> data,
+                    std::optional<lens::ImageEncodingOptions> image_options) {
+        uploaded_token = file_token;
+      });
+
+  auto data = std::make_unique<lens::ContextualInputData>();
+  data->tab_session_id = session_id;
+  data->page_url = GURL("about:blank");
+  data->page_title = "about:blank";
+  data->context_id = 12345;
+  data->is_page_context_eligible = true;
+  std::move(pending_callback).Run(std::move(data));
+
+  // 4. Destroy the handler while upload is still in progress in UploadTracker.
+  handler_.reset();
+
+  // 5. Complete the upload status change. Must safely no-op without UAF.
+  SimulateUploadStatusChanged(
+      uploaded_token, lens::MimeType::kUnknown,
+      contextual_search::ContextUploadStatus::kUploadSuccessful);
+}
+
 IN_PROC_BROWSER_TEST_F(ContextualTasksComposeboxHandlerTest,
                        Recontextualization_TabInvalidatedGracefullyCompletes) {
   ASSERT_NE(mock_contextual_tasks_service_ptr_, nullptr)
Loading diff…

Original Bug Report

reported by xu...@gmail.com

Heap-use-after-free in ContextualTasksComposeboxHandler upload callback

Steps to reproduce the problem

Method A: the ASAN Chromium build at the reported revision 1dfbd5b0b1a1374759b99ba1dc25afff78180b4f (out/asan, is_asan=true, is_debug=false).

  1. Unpack method-a/ (run.sh, aim.html, server80.py, server_blackhole.py, ext/).

  2. Start the two local stand-in servers (run.sh does this):

    • GAIPAGE=aim.html python3 server80.py — plain HTTP on 127.0.0.1:80. aim.html stands in for a compromised AIM-origin renderer; the boundary under test is guest web renderer → browser process, which hosting on localhost does not change (same framing as accepted report 517082351).
    • python3 server_blackhole.py — a raw TCP sink on 127.0.0.1:443, no TLS and no certificate. It accepts and never answers, so the Lens URL upload stays pending across the teardown window.
  3. Launch Chrome with exactly these 8 flags (run.sh does this):

--user-data-dir=<fresh profile>
--load-extension=<this-dir>/ext
--enable-logging=stderr
--enable-features=ContextualTasksSidePanel,ContextualTasks<CTE.E:contextual-tasks-ai-page-url/http%3A%2F%2Fg.ai
--host-resolver-rules=MAP g.ai 127.0.0.1,MAP lensfrontend-pa.googleapis.com 127.0.0.1,MAP lensfrontend-pa.clients6.google.com 127.0.0.1
--no-proxy-server
--browser-test
--no-first-run
about:blank

None gate the vulnerable path: the feature parameters only enable the Finch-disabled feature and point the embedded page at the local server; --host-resolver-rules points the upload endpoints at the sink and satisfies the hardcoded g.ai check in IsAiUrl(); --browser-test only suppresses the ineligible-profile redirect. --no-sandbox and --ignore-certificate-errors are not used; the extension holds only the tabs permission.

  1. The extension opens chrome://contextual-tasks/ with chrome.tabs.create. The WebUI’s <webview> guest commits the AI page served from 127.0.0.1:80; a task is created.

  2. aim.html completes the handshake over the documented guest→embedder channel (replying on event.source; see webview_tag.json), then sends a serialized lens::AimToClientMessage{inject_input{query_text: "summarize https://g.ai/a1 https://g.ai/a2 please", submit_after_injection: true}}.

  3. The message travels the stock path into CreateAndSendQueryMessage() (chain below), arming the self-retaining UploadTracker with two pending URL-upload tokens. The sink holds both requests open.

  4. Six seconds after the task appears, the extension calls chrome.tabs.remove(). ContextualTasksUI and its per-UI ContextualTasksComposeboxHandler are destroyed; the UploadTracker survives (self_ref_, query_contextualizer.cc:72) and still observes the session-scoped controller.

  5. WebContents teardown then reaches UploadTracker::CheckCompletion() (chain below), where the base::Unretained(this) continuation runs against the already-freed handler → ASAN heap-use-after-free (output.log). Method B: The attached browser-test.patch adds one browsertest:

autoninja -C out/asan browser_tests
out/asan/browser_tests.exe --gtest_filter=ContextualTasksComposeboxHandlerTest.Sc001_UafWhenHandlerDestroyedWithPendingUpload

Same chain, same stack: [1/1] ... (CRASHED).

Problem Description

ContextualTasksComposeboxHandler::CreateAndSendQueryMessage() builds the callback it will run once the query’s URL/tab uploads have finished, and binds it to the per-UI handler with base::Unretained(this) (chrome/browser/contextual_tasks/contextual_tasks_composebox_handler.cc):

recontextualization_pending_count_++;
// It is safe to use base::Unretained(this) here because `recontextualizer_`
// is owned by `this` and will be destroyed when `this` is destroyed,
// cancelling any pending callbacks.
auto callback = base::BindOnce(
    [](ContextualTasksComposeboxHandler* handler, std::string query,
       std::optional<base::Uuid> task_id,
       std::optional<base::UnguessableToken> token, bool voice,
       std::map<std::string, std::string> cgi_params,
       base::WeakPtr<contextual_search::ContextualSearchSessionHandle> handle) {
      handler->ContinueCreateAndSendQueryMessage(query, task_id, token, voice,
                                                 std::move(cgi_params));
    },
    base::Unretained(this), query, task_id, overlay_token, is_voice_search,
    additional_cgi_params);

The comment’s premise is false. The callback is not retained by QueryContextualizer; it is moved into UploadTracker, which self-retains and observes the session-scoped ContextualSearchContextController:

// components/contextual_tasks/public/query_contextualizer.cc
if (!pending_tokens_.empty()) {
  self_ref_ = base::WrapRefCounted(this);          // :72 — outlives the handler
}
...
void UploadTracker::CheckCompletion() {
  if (uploads_started_ && pending_tokens_.empty()) {
    ...
    if (callback_) {
      std::move(callback_).Run(session_handle_);   // :122 — into freed memory
    }
    self_ref_.reset();
  }
}

Trigger path (repro steps 6 and 8). The attacker-reachable half runs the stock message path — PageHandler.OnWebviewMessage → OnReceivedInjectInput → composebox.injectInput() → SubmitQuery → CreateAndSendQueryMessage(). The destruction half runs at WebContents teardown — ~ContextualSearchWebContentsHelper → ContextualSearchService::ReleaseSession → ~ComposeboxQueryController → UploadTracker::OnControllerDestroyed() → CheckCompletion() — whose ASAN report is attached as output.log:

#0 ContextualTasksComposeboxHandler::ContinueCreateAndSendQueryMessage
       contextual_tasks_composebox_handler.cc:735:7
#1 ContextualTasksComposeboxHandler::CreateAndSendQueryMessage::<lambda_0>
       contextual_tasks_composebox_handler.cc:485
#7 UploadTracker::CheckCompletion   query_contextualizer.cc:122:30
#8 UploadTracker::OnControllerDestroyed   query_contextualizer.cc:96:5
freed by: ContextualTasksComposeboxHandler::'scalar deleting dtor'
       contextual_tasks_composebox_handler.h:262
       from ContextualTasksUI::~ContextualTasksUI  contextual_tasks_ui.cc:454
allocated by: ContextualTasksUI::CreatePageHandler  contextual_tasks_ui.cc:1096

When the per-UI handler is destroyed while a URL upload is still in flight, the first terminal upload status — or controller teardown — runs that continuation against freed memory. ContextualTasksComposeboxHandler::~ContextualTasksComposeboxHandler() (:262) only nulls web_ui_interface_; it cancels nothing.

Reachability. The vulnerable state is entered from ContextualTasksPageHandler::OnWebviewMessage, which deserializes a lens::AimToClientMessage forwarded from the embedded <webview> guest. That a compromised guest renderer can drive this interface — including the inject_input primitive used here — is already established and accepted: crbug 517082351’s fix states “This ensures a compromised renderer cannot initiate a navigation to file or chrome internal urls”, and crbug 502887575’s impact section names this exact primitive: “The attacker can send arbitrary lens::AimToClientMessage protobufs to the C++ browser process via ContextualTasksPageHandler::OnWebviewMessage … including spoofing interactive UI chips (inject_input).” Those reports treat the result as UI spoofing / data disclosure; this one shows the same accepted primitive also yields a browser-process memory-safety violation. The destruction half is an ordinary UI teardown (closing the panel or the tab), so the bug is also reachable by a normal user with no attacker at all.

Summary

Heap-use-after-free in ContextualTasksComposeboxHandler upload callback

Custom Questions

Type of crash:

browser

Additional Data

Category: Security
Chrome Channel: Not sure
Regression: N/A \

View on issue tracker
Links in the report