CVE-2026-8550
Overview
Background
- Lens Overlay query controller
LensOverlayQueryControllergathers page content and uploads it to the Lens backend, offloading compression to a background task runner.- `base::span<const lens::PageContent>`
- A non-owning view over
underlying_page_contents_; if the underlying storage is cleared or mutated before the viewer is used, the span dangles. - `PostTaskAndReplyWithResult` / `compression_task_tracker_`
- Posts work to another thread and replies on the main thread; the tracker can cancel outstanding tasks via
TryCancelAll(). - `page_content_request_in_progress_`
- A flag intended to record that a page-content request is underway so overlapping requests/state can be reasoned about.
Root Cause Analysis
PrepareAndFetchPageContentRequest (hunk around line 1318, enclosing name not shown in the header) posted CreatePageContentPayload / CreatePageContentPayloadForChunks to a background thread while passing underlying_page_contents_ as a base::span<const lens::PageContent> — a borrowed view into a member container. If underlying_page_contents_ was cleared, reassigned, or the controller’s state reset on the main thread before the background compression task ran, the span dangled and the background task read freed PageContent memory, a use-after-free.
The fix changes CreatePageContentPayload to take std::vector<lens::PageContent> by value and constructs a contents_copy that is std::moved into the bound task, giving the background work its own owned copy with guaranteed lifetime; CreatePageContentPayloadForChunks drops the page_content span parameter entirely. The CL also fixes a cancellation-ordering bug: compression_task_tracker_->TryCancelAll() was moved before the underlying_page_contents_.empty() early-return (previously it ran only after, so early returns skipped cancellation), and adds page_content_request_in_progress_ = false to the reset path plus folds that flag into IsPageContentUploadInProgress() so in-flight requests are tracked correctly.
base::span borrowing underlying_page_contents_ whose lifetime the task could outlive; the fix passes an owned std::vector<lens::PageContent> copy by value (plus reorders TryCancelAll() and tracks page_content_request_in_progress_) so the task never references freed content.Attack Path
- Start a page-content upload
A Lens Overlay page-content request is initiated, posting a background compression task that references
underlying_page_contents_by span. - Free/reset the backing content
Before the background task runs, main-thread activity (a new request, state reset, or cancellation that pre-patch was skipped on the early-return path) clears or replaces
underlying_page_contents_, dangling the span. - Background task reads freed memory
The compression task executes
CreatePageContentPayload*over the danglingPageContentview, a use-after-free. - Corruption/control (inferred) Exploiting the UAF for information disclosure or control is (inferred); the diff demonstrates only the lifetime violation and its fix.
Impact Assessment
Files Changed
chrome/browser/ui/lens/lens_overlay_query_controller.ccchrome/browser/ui/lens/lens_overlay_query_controller.h
Audit Directions
- `base::span`/raw refs bound into cross-thread tasksAudit
PostTask*/BindOncecalls that pass abase::spanor reference to a member container; such borrows must outlive the task or be copied by value / moved. - Cancellation ordering around early returnsVerify
TryCancelAll()/teardown runs on all exit paths (including earlyreturns) so outstanding tasks referencing soon-to-be-freed state are actually cancelled. - In-progress flags vs. object presenceWhere “is a request in progress” is inferred from a pointer being non-null, check for gaps (e.g., posted-but-not-yet-constructed fetcher) that an explicit boolean like
page_content_request_in_progress_must cover.
Patch
From 4af1bce9f2ecb4ad514d3fc4e3f170f707180ced Mon Sep 17 00:00:00 2001
From: Duncan Mercer <mercerd@google.com>
Date: Thu, 02 Apr 2026 12:29:55 -0700
Subject: [PATCH] [Lens Overlay] Fix Use-After-Free in query controller
Pass PageContent by value to background threads to ensure lifetime
safety. Also fix task cancellation bypass and state reset.
Fixed: 498322453
Change-Id: If2282782cf97630604f5e6220666035f0294c989
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7723469
Reviewed-by: Juan Mojica <juanmojica@google.com>
Commit-Queue: Duncan Mercer <mercerd@google.com>
Cr-Commit-Position: refs/heads/main@{#1609432}
---
diff --git a/chrome/browser/ui/lens/lens_overlay_query_controller.cc b/chrome/browser/ui/lens/lens_overlay_query_controller.cc
index 24a417ab..789cffd0 100644
--- a/chrome/browser/ui/lens/lens_overlay_query_controller.cc
+++ b/chrome/browser/ui/lens/lens_overlay_query_controller.cc
@@ -297,7 +297,6 @@
// Returns the lens::Payload to be sent after uploading chunked data using the
// repeated Content field instead of the deprecated payload fields.
lens::Payload CreatePageContentPayloadForChunks(
- base::span<const lens::PageContent> page_content,
lens::MimeType primary_content_type,
GURL page_url,
std::optional<std::string> page_title,
@@ -328,7 +327,7 @@
// Returns the lens::Payload using the repeated Content field instead of the
// deprecated payload fields.
lens::Payload CreatePageContentPayload(
- base::span<const lens::PageContent> page_contents,
+ std::vector<lens::PageContent> page_contents,
GURL page_url,
std::optional<std::string> page_title) {
lens::Payload payload;
@@ -505,6 +504,7 @@
page_url_ = GURL();
page_title_ = std::nullopt;
partial_content_ = base::span<const std::u16string>();
+ page_content_request_in_progress_ = false;
}
void LensOverlayQueryController::SendUpdatedPageContent(
@@ -1270,13 +1270,13 @@
return;
}
+ compression_task_tracker_->TryCancelAll();
+
if (underlying_page_contents_.empty() ||
underlying_page_contents_.front().bytes_.empty()) {
// No need to send the request without underlying content bytes.
return;
}
-
- compression_task_tracker_->TryCancelAll();
page_contents_request_start_time_ = base::TimeTicks::Now();
page_content_request_in_progress_ = true;
chunk_upload_in_progress_ = false;
@@ -1318,9 +1318,11 @@
} else {
// Post CreatePageContentPayload to a task off the main thread so
// compression does not throttle the main thread.
+ std::vector<lens::PageContent> contents_copy(
+ underlying_page_contents_.begin(), underlying_page_contents_.end());
compression_task_tracker_->PostTaskAndReplyWithResult(
compression_task_runner_.get(), FROM_HERE,
- base::BindOnce(&CreatePageContentPayload, underlying_page_contents_,
+ base::BindOnce(&CreatePageContentPayload, std::move(contents_copy),
page_url_, page_title_),
base::BindOnce(
&LensOverlayQueryController::PrepareAndFetchPageContentRequestPart2,
@@ -1416,9 +1418,8 @@
base::SequencedTaskRunner::GetCurrentDefault()->PostTaskAndReplyWithResult(
FROM_HERE,
base::BindOnce(&CreatePageContentPayloadForChunks,
- underlying_page_contents_, primary_content_type_,
- page_url_, page_title_, total_chunks,
- retrying_page_content_upload_),
+ primary_content_type_, page_url_, page_title_,
+ total_chunks, retrying_page_content_upload_),
base::BindOnce(
&LensOverlayQueryController::PrepareAndFetchPageContentRequestPart2,
weak_ptr_factory_.GetWeakPtr(), request_id));
diff --git a/chrome/browser/ui/lens/lens_overlay_query_controller.h b/chrome/browser/ui/lens/lens_overlay_query_controller.h
index 20302998..a9877b2 100644
--- a/chrome/browser/ui/lens/lens_overlay_query_controller.h
+++ b/chrome/browser/ui/lens/lens_overlay_query_controller.h
@@ -199,7 +199,8 @@
lens::mojom::SemanticEvent event);
bool IsPageContentUploadInProgress() const {
- return page_content_endpoint_fetcher_.get() != nullptr;
+ return page_content_request_in_progress_ ||
+ page_content_endpoint_fetcher_.get() != nullptr;
}
// Returns whether the query controller is off.