CVE-2026-103624
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/contextual_tasks/contextual_tasks_composebox_handler.cc |
modified |
Files Changed
chrome/browser/contextual_tasks/contextual_tasks_composebox_handler.ccchrome/browser/contextual_tasks/contextual_tasks_composebox_handler_browsertest.cc
Patch
From e6f08253f947029305f150301d2c7d9bd8b18f07 Mon Sep 17 00:00:00 2001 From: Jason Hu <[email protected]> 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 <[email protected]> Reviewed-by: Nihar Majmudar <[email protected]> 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)
Regression Test / PoC
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)
Original Bug Report
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).
-
Unpack
method-a/(run.sh,aim.html,server80.py,server_blackhole.py,ext/). -
Start the two local stand-in servers (
run.shdoes this):GAIPAGE=aim.html python3 server80.py— plain HTTP on127.0.0.1:80.aim.htmlstands 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 on127.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.
-
Launch Chrome with exactly these 8 flags (
run.shdoes 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.
-
The extension opens
chrome://contextual-tasks/withchrome.tabs.create. The WebUI’s<webview>guest commits the AI page served from127.0.0.1:80; a task is created. -
aim.htmlcompletes the handshake over the documented guest→embedder channel (replying onevent.source; see webview_tag.json), then sends a serializedlens::AimToClientMessage{inject_input{query_text: "summarize https://g.ai/a1 https://g.ai/a2 please", submit_after_injection: true}}. -
The message travels the stock path into
CreateAndSendQueryMessage()(chain below), arming the self-retainingUploadTrackerwith two pending URL-upload tokens. The sink holds both requests open. -
Six seconds after the task appears, the extension calls
chrome.tabs.remove().ContextualTasksUIand its per-UIContextualTasksComposeboxHandlerare destroyed; theUploadTrackersurvives (self_ref_, query_contextualizer.cc:72) and still observes the session-scoped controller. -
WebContents teardown then reaches
UploadTracker::CheckCompletion()(chain below), where thebase::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 \