CVE-2026-19149
Overview
Background
- `x11::Future`
- A wrapper around a pending X11 request reply, whose response can be taken synchronously with
Sync()or handled by a callback that was installed withOnResponse()(ui/gfx/x/future.h:88-150). - `FutureImpl::DispatchNow`
- A call that blocks in
Wait()and then runsProcessResponse(), which calls the request’s installed response callback “out-of-order” (ui/gfx/x/future.cc:25-28,ui/gfx/x/future.cc:54-63,ui/gfx/x/future.h:38-41). - `x11::GeometryCache`
- A per-window cache of the X window’s parent and geometry;
GetBoundsPx()computes absolute bounds by recursing up the parent chain, andNotifyGeometryChanged()reports changes throughbounds_changed_callback_(ui/gfx/x/geometry_cache.cc:36-62,ui/gfx/x/geometry_cache.cc:119-137). - `X11Window::GetBoundsInPixels`
- The Ozone/X11 bounds getter that returns
geometry_cache_->GetBoundsPx()unless a WM sync is pending, as shown in the companion diff’s context lines inui/ozone/platform/x11/x11_window.cc.
Root Cause Analysis
afcbd943b2ea. An earlier analysis was withdrawn because it had been written against a commit that was not the security fix (fc4613ff876d).Before the fix, GeometryCache::GetBoundsPx() was a getter that could run arbitrary callbacks. When the parent or geometry was not yet known, it called parent_future_.DispatchNow() (ui/gfx/x/geometry_cache.cc:39) or geometry_future_.DispatchNow() (ui/gfx/x/geometry_cache.cc:46). DispatchNow() runs the response callback synchronously (ui/gfx/x/future.cc:25-28, ui/gfx/x/future.cc:61-62). Those callbacks are OnQueryTreeResponse and OnGetGeometryResponse (ui/gfx/x/geometry_cache.cc:27-31), and they lead through OnParentChanged/OnGeometryChanged to NotifyGeometryChanged() (ui/gfx/x/geometry_cache.cc:94, ui/gfx/x/geometry_cache.cc:101). The same happens when GetBoundsPx() recurses into an un-ready parent cache (ui/gfx/x/geometry_cache.cc:56): the parent’s notification goes through the child’s OnParentGeometryChanged (ui/gfx/x/geometry_cache.cc:88-89, ui/gfx/x/geometry_cache.cc:113-117), and the child then runs bounds_changed_callback_.Run(...) (ui/gfx/x/geometry_cache.cc:136), which calls into the window’s bounds-changed handling. The removed x11_window.cc comment admits this: “geometry_cache_->GetBoundsPx() can dispatch pending X server events synchronously, which can trigger window destruction and invalidate this”. So any caller of a bounds getter, including (per the reporter) WindowTreeHost::OnDisplayMetricsChanged through GetDisplayId(), could see its own object freed partway through an expression and then read or write it. That is the reported display_color_spaces_ / compositor_ use after free in ui/aura/window_tree_host.cc (shown only in the report, so (inferred) here). The pre-fix weak_this checks inside GetBoundsPx() (ui/gfx/x/geometry_cache.cc:37, ui/gfx/x/geometry_cache.cc:40-42, ui/gfx/x/geometry_cache.cc:57-59) protected only the GeometryCache itself, not its callers.
The fix commit afcbd943b2ea adds FutureImpl::Peek(). It calls Wait() and copies request->reply without touching request->callback. GetBoundsPx() now uses parent_future_.Peek() and geometry_future_.Peek() to compute bounds locally, so bounds callbacks run only later, “in order when the event loop processes the responses”. The companion commit fc4613ff876d then removes the per-caller weak_this workaround guards in X11Window, DesktopWindowTreeHostPlatform::CenterWindow, X11DesktopWindowMoveClient and BrowserDesktopWindowTreeHostLinux. It depends on the first commit and does not fix anything itself.
GetBoundsPx()) synchronously ran X11 reply callbacks through DispatchNow(), and those callbacks could destroy the window and its host in the middle of the caller’s code. The fix stops the getter from dispatching callbacks: Peek() reads the reply without running any reply callback, so no caller can be freed inside a bounds query. The getter is not fully side-effect-free, because it can still call parent_.reset() or replace parent_ with a new GeometryCache, whose constructor issues fresh QueryTree/GetGeometry requests (geometry_cache.cc:26-31).Attack Path
- Make the geometry cache un-ready
A window’s
GeometryCacheneeds to wait on a reply, for example after aReparentNotifyreplacesparent_with a fresh cache that has pendingQueryTree/GetGeometryfutures (ui/gfx/x/geometry_cache.cc:150-153,ui/gfx/x/geometry_cache.cc:85-90). - Reach a bounds query from a host method
Per the reporter, a display color-space metric change runs
WindowTreeHost::OnDisplayMetricsChanged, which callsGetDisplayId()and from there the X11 window’sGetBoundsInPixels()(inferred; only the reporter’s text shows this chain). - Synchronous callback dispatch
GetBoundsPx()callsDispatchNow()(ui/gfx/x/geometry_cache.cc:39,ui/gfx/x/geometry_cache.cc:46, or through the parent atui/gfx/x/geometry_cache.cc:56), which leads tobounds_changed_callback_.Run(ui/gfx/x/geometry_cache.cc:136). - Re-entrant teardown
A bounds-changed observer closes the widget, destroying the
X11Window, theWindowTreeHostand itsCompositor(inferred; the companion’s removed tests model this withwindow.reset()insideOnBoundsChanged). - Use of freed host
Control returns to the calling expression, which reads
display_color_spaces_and callscompositor_->SetDisplayColorSpaces(...)on freed memory (reporter’s claim; inferred from the material).
Impact Assessment
WindowTreeHost/Compositor (and in related paths, X11Window) objects in the browser process on Linux with the Ozone/X11 backend. Per the reporter, the trigger depends on X11 window-manager or same-display client activity that leaves the geometry cache waiting on replies while a display-metrics change runs; turning this into code execution is (inferred). The advisory rates it critical and does not report in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifui/gfx/x/future.h |
modified | |
ifui/gfx/x/geometry_cache.cc |
modified | |
TESTui/gfx/x/geometry_cache_unittest.cc |
modified | |
EXPECT_TRUEui/ozone/platform/x11/x11_window_ozone_unittest.cc |
modified |
Files Changed
ui/gfx/x/future.ccui/gfx/x/future.hui/gfx/x/geometry_cache.ccui/gfx/x/geometry_cache_unittest.ccui/ozone/platform/x11/x11_window_ozone_unittest.cc
Audit Directions
- Getters that dispatchLook for “read” APIs that call
DispatchNow(),ProcessResponse()or run nested event loops; any caller holdingthisacross them can be freed partway through an expression. - Point-fix guards vs. root causeThe several
weak_thisguards that were added and then reverted show that per-caller patches miss call sites such asWindowTreeHost::OnDisplayMetricsChanged. Check otherFuture::DispatchNow()users for the same re-entrancy. - Peek semanticsCheck that
FutureImpl::Peek()setting*error = nullptrand leavinghave_parent_unset cannot cause stale or inconsistent geometry once the queuedOnQueryTreeResponse/OnGetGeometryResponsecallbacks later run.
Patch
diff --git a/ui/gfx/x/future.cc b/ui/gfx/x/future.cc
index a0d2ec39..0524b22 100644
--- a/ui/gfx/x/future.cc
+++ b/ui/gfx/x/future.cc
@@ -36,6 +36,17 @@
TakeResponse(raw_reply, error);
}
+void FutureImpl::Peek(RawReply* raw_reply, std::unique_ptr<Error>* error) {
+ DCHECK_CALLED_ON_VALID_SEQUENCE(connection_->sequence_checker_);
+ Wait();
+
+ auto* request = connection_->GetRequestForFuture(this);
+ CHECK(request->have_response);
+
+ *raw_reply = request->reply;
+ *error = nullptr;
+}
+
void FutureImpl::OnResponse(ResponseCallback callback) {
UpdateRequestHandler(std::move(callback));
}
diff --git a/ui/gfx/x/future.h b/ui/gfx/x/future.h
index 2141a29..6be59a2 100644
--- a/ui/gfx/x/future.h
+++ b/ui/gfx/x/future.h
@@ -28,6 +28,10 @@
void Sync(RawReply* raw_reply, std::unique_ptr<Error>* error);
+ // Similar to Sync(), but does not remove the response from the connection's
+ // queue or clear the response callback.
+ void Peek(RawReply* raw_reply, std::unique_ptr<Error>* error);
+
void OnResponse(ResponseCallback callback);
// Update an existing Request with a new handler. |sequence| must
@@ -102,7 +106,8 @@
"to FutureBase");
}
- // Blocks until we receive the response from the server. Returns the response.
+ // Blocks until the response is received from the server. Returns the
+ // response, removing it from the request queue.
Response<Reply> Sync() {
if (!impl()) {
return {nullptr, nullptr};
@@ -114,7 +119,29 @@
std::unique_ptr<Reply> reply;
if (raw_reply) {
- auto buf = ReadBuffer(raw_reply);
+ ReadBuffer buf(raw_reply);
+ reply = detail::ReadReply<Reply>(&buf);
+ }
+
+ return {std::move(reply), std::move(error)};
+ }
+
+ // Blocks until the response is received from the server. Returns the response
+ // without taking it from the request queue or clearing the response callback.
+ // Unlike Sync(), this method does not remove the response from the request
+ // queue.
+ Response<Reply> Peek() {
+ if (!impl()) {
+ return {nullptr, nullptr};
+ }
+
+ RawReply raw_reply;
+ std::unique_ptr<Error> error;
+ impl()->Peek(&raw_reply, &error);
+
+ std::unique_ptr<Reply> reply;
+ if (raw_reply) {
+ ReadBuffer buf(raw_reply);
reply = detail::ReadReply<Reply>(&buf);
}
@@ -185,4 +212,4 @@
} // namespace x11
-#endif // UI_GFX_X_FUTURE_H_
+#endif // UI_GFX_X_FUTURE_H_
diff --git a/ui/gfx/x/geometry_cache.cc b/ui/gfx/x/geometry_cache.cc
index d7b2c05..a36c897e 100644
--- a/ui/gfx/x/geometry_cache.cc
+++ b/ui/gfx/x/geometry_cache.cc
@@ -34,31 +34,43 @@
GeometryCache::~GeometryCache() = default;
gfx::Rect GeometryCache::GetBoundsPx() {
- auto weak_this = weak_ptr_factory_.GetWeakPtr();
- if (!have_parent_) {
- parent_future_.DispatchNow();
- if (!weak_this) {
- return {};
+ auto get_local_geometry = [this]() {
+ if (have_geometry_) {
+ return geometry_;
}
- }
- CHECK(have_parent_);
- if (!have_geometry_) {
- geometry_future_.DispatchNow();
- if (!weak_this) {
- return {};
+ if (auto response = geometry_future_.Peek()) {
+ return response ? gfx::Rect(response->x, response->y, response->width,
+ response->height)
+ : gfx::Rect();
}
- }
- CHECK(have_geometry_);
-
- if (!parent_) {
return geometry_;
+ };
+
+ if (!have_parent_) {
+ auto response = parent_future_.Peek();
+ if (!response) {
+ return get_local_geometry();
+ }
+ Window parent_window = response ? response->parent : Window::None;
+ if (parent_window == Window::None) {
+ parent_.reset();
+ return get_local_geometry();
+ }
+ if (!parent_ || parent_->window_ != parent_window) {
+ parent_ = std::make_unique<GeometryCache>(
+ connection_, parent_window,
+ base::BindRepeating(&GeometryCache::OnParentGeometryChanged,
+ weak_ptr_factory_.GetWeakPtr()));
+ }
+ }
+
+ gfx::Rect geometry = get_local_geometry();
+ if (!parent_) {
+ return geometry;
}
auto parent_bounds = parent_->GetBoundsPx();
- if (!weak_this) {
- return {};
- }
gfx::Vector2d offset(parent_bounds.x(), parent_bounds.y());
- return geometry_ + offset;
+ return geometry + offset;
}
void GeometryCache::OnQueryTreeResponse(QueryTreeResponse response) {
@@ -121,12 +133,7 @@
return;
}
- auto weak_this = weak_ptr_factory_.GetWeakPtr();
auto geometry = GetBoundsPx();
- if (!weak_this) {
- return;
- }
-
if (last_notified_geometry_ == geometry) {
return;
}
diff --git a/ui/gfx/x/geometry_cache_unittest.cc b/ui/gfx/x/geometry_cache_unittest.cc
index 4e876ed2..7312e1b3 100644
--- a/ui/gfx/x/geometry_cache_unittest.cc
+++ b/ui/gfx/x/geometry_cache_unittest.cc
@@ -101,8 +101,11 @@
EXPECT_FALSE(last_bounds.has_value());
EXPECT_EQ(geometry_cache.GetBoundsPx(), bounds);
- // `last_bounds` should have gotten set after the call to GetBoundsPx().
- // Reset it for the next part of the test.
+ // Calling GetBoundsPx() should not trigger the callback synchronously.
+ EXPECT_FALSE(last_bounds.has_value());
+
+ // Dispatching responses/events will invoke the callback.
+ connection->DispatchAll();
EXPECT_TRUE(last_bounds.has_value());
last_bounds = std::nullopt;
@@ -176,9 +179,8 @@
connection, window.id(),
base::BindRepeating(bounds_changed_callback, &geometry_cache));
- // This will trigger the callback via DispatchNow() and then return.
- // If there's a UAF, this should crash or trigger ASAN.
geometry_cache->GetBoundsPx();
+ connection->DispatchAll();
}
TEST(GeometryCacheTest, DestroyInParentGeometryChangedCallback) {
diff --git a/ui/ozone/platform/x11/x11_window_ozone_unittest.cc b/ui/ozone/platform/x11/x11_window_ozone_unittest.cc
index b62a827..2a5b43ef 100644
--- a/ui/ozone/platform/x11/x11_window_ozone_unittest.cc
+++ b/ui/ozone/platform/x11/x11_window_ozone_unittest.cc
@@ -2,27 +2,27 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-#include "ui/ozone/platform/x11/x11_window.h"
-#include "ui/platform_window/extensions/x11_extension_delegate.h"
-#include "base/nix/xdg_util.h"
-#include "ui/base/x/x11_util.h"
-#include "ui/gfx/x/atom_cache.h"
-
#include <memory>
#include <utility>
+#include "base/nix/xdg_util.h"
#include "base/run_loop.h"
+#include "base/test/run_until.h"
#include "base/test/task_environment.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "ui/base/x/x11_util.h"
#include "ui/display/display.h"
#include "ui/display/screen_base.h"
#include "ui/events/devices/x11/touch_factory_x11.h"
#include "ui/events/event.h"
#include "ui/events/platform/x11/x11_event_source.h"
#include "ui/events/test/events_test_utils_x11.h"
+#include "ui/gfx/x/atom_cache.h"
#include "ui/gfx/x/event.h"
+#include "ui/ozone/platform/x11/x11_window.h"
#include "ui/ozone/platform/x11/x11_window_manager.h"
#include "ui/ozone/test/mock_platform_window_delegate.h"
+#include "ui/platform_window/extensions/x11_extension_delegate.h"
#include "ui/platform_window/platform_window_delegate.h"
#include "ui/platform_window/platform_window_init_properties.h"
@@ -481,10 +481,11 @@
x11::Event map_event(/*send_event=*/false, std::move(map_notify));
connection->DispatchEvent(map_event);
- // The synchronous-free path must have been exercised; otherwise the test
- // didn't reach the vulnerable frame.
- EXPECT_TRUE(freed) << "delegate->OnBoundsChanged() was not invoked "
- "synchronously from OnWindowMapped/Maximize";
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ connection->DispatchAll();
+ return freed;
+ })) << "delegate->OnBoundsChanged() was not invoked from "
+ "OnWindowMapped/Maximize";
connection->DestroyWindow({new_parent});
}
@@ -554,10 +555,10 @@
auto* x11_window_raw = static_cast<X11Window*>(window.get());
x11_window_raw->SetOverrideRedirect(true);
- // The synchronous-free path must have been exercised; otherwise the test
- // didn't reach the vulnerable frame.
- EXPECT_TRUE(freed) << "delegate->OnBoundsChanged() was not invoked "
- "synchronously from SetOverrideRedirect";
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ connection->DispatchAll();
+ return freed;
+ })) << "delegate->OnBoundsChanged() was not invoked from SetOverrideRedirect";
connection->DestroyWindow({new_parent});
}
@@ -635,9 +636,10 @@
// Dispatch the event to window2.
DispatchXEvent(xi_event, widget2);
- // Verify that window1 was indeed synchronously freed during the geometry
- // fetch.
- EXPECT_TRUE(freed) << "grabber delegate was never invoked synchronously";
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ connection->DispatchAll();
+ return freed;
+ })) << "grabber delegate was never invoked";
connection->DestroyWindow({new_parent});
}
@@ -712,9 +714,10 @@
// Step 7: Dispatch the event to widget. This will trigger DispatchEvent.
DispatchXEvent(xi_event, widget);
- // If we got here without ASAN/crash tripping, verify the window was indeed
- // freed.
- EXPECT_TRUE(freed) << "delegate was never invoked synchronously";
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ connection->DispatchAll();
+ return freed;
+ })) << "delegate was never invoked";
connection->DestroyWindow({new_parent});
}
Regression Test / PoC
diff --git a/ui/gfx/x/geometry_cache_unittest.cc b/ui/gfx/x/geometry_cache_unittest.cc
index 4e876ed2..7312e1b3 100644
--- a/ui/gfx/x/geometry_cache_unittest.cc
+++ b/ui/gfx/x/geometry_cache_unittest.cc
@@ -101,8 +101,11 @@
EXPECT_FALSE(last_bounds.has_value());
EXPECT_EQ(geometry_cache.GetBoundsPx(), bounds);
- // `last_bounds` should have gotten set after the call to GetBoundsPx().
- // Reset it for the next part of the test.
+ // Calling GetBoundsPx() should not trigger the callback synchronously.
+ EXPECT_FALSE(last_bounds.has_value());
+
+ // Dispatching responses/events will invoke the callback.
+ connection->DispatchAll();
EXPECT_TRUE(last_bounds.has_value());
last_bounds = std::nullopt;
@@ -176,9 +179,8 @@
connection, window.id(),
base::BindRepeating(bounds_changed_callback, &geometry_cache));
- // This will trigger the callback via DispatchNow() and then return.
- // If there's a UAF, this should crash or trigger ASAN.
geometry_cache->GetBoundsPx();
+ connection->DispatchAll();
}
TEST(GeometryCacheTest, DestroyInParentGeometryChangedCallback) {
diff --git a/ui/ozone/platform/x11/x11_window_ozone_unittest.cc b/ui/ozone/platform/x11/x11_window_ozone_unittest.cc
index b62a827..2a5b43ef 100644
--- a/ui/ozone/platform/x11/x11_window_ozone_unittest.cc
+++ b/ui/ozone/platform/x11/x11_window_ozone_unittest.cc
@@ -2,27 +2,27 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-#include "ui/ozone/platform/x11/x11_window.h"
-#include "ui/platform_window/extensions/x11_extension_delegate.h"
-#include "base/nix/xdg_util.h"
-#include "ui/base/x/x11_util.h"
-#include "ui/gfx/x/atom_cache.h"
-
#include <memory>
#include <utility>
+#include "base/nix/xdg_util.h"
#include "base/run_loop.h"
+#include "base/test/run_until.h"
#include "base/test/task_environment.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "ui/base/x/x11_util.h"
#include "ui/display/display.h"
#include "ui/display/screen_base.h"
#include "ui/events/devices/x11/touch_factory_x11.h"
#include "ui/events/event.h"
#include "ui/events/platform/x11/x11_event_source.h"
#include "ui/events/test/events_test_utils_x11.h"
+#include "ui/gfx/x/atom_cache.h"
#include "ui/gfx/x/event.h"
+#include "ui/ozone/platform/x11/x11_window.h"
#include "ui/ozone/platform/x11/x11_window_manager.h"
#include "ui/ozone/test/mock_platform_window_delegate.h"
+#include "ui/platform_window/extensions/x11_extension_delegate.h"
#include "ui/platform_window/platform_window_delegate.h"
#include "ui/platform_window/platform_window_init_properties.h"
@@ -481,10 +481,11 @@
x11::Event map_event(/*send_event=*/false, std::move(map_notify));
connection->DispatchEvent(map_event);
- // The synchronous-free path must have been exercised; otherwise the test
- // didn't reach the vulnerable frame.
- EXPECT_TRUE(freed) << "delegate->OnBoundsChanged() was not invoked "
- "synchronously from OnWindowMapped/Maximize";
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ connection->DispatchAll();
+ return freed;
+ })) << "delegate->OnBoundsChanged() was not invoked from "
+ "OnWindowMapped/Maximize";
connection->DestroyWindow({new_parent});
}
@@ -554,10 +555,10 @@
auto* x11_window_raw = static_cast<X11Window*>(window.get());
x11_window_raw->SetOverrideRedirect(true);
- // The synchronous-free path must have been exercised; otherwise the test
- // didn't reach the vulnerable frame.
- EXPECT_TRUE(freed) << "delegate->OnBoundsChanged() was not invoked "
- "synchronously from SetOverrideRedirect";
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ connection->DispatchAll();
+ return freed;
+ })) << "delegate->OnBoundsChanged() was not invoked from SetOverrideRedirect";
connection->DestroyWindow({new_parent});
}
@@ -635,9 +636,10 @@
// Dispatch the event to window2.
DispatchXEvent(xi_event, widget2);
- // Verify that window1 was indeed synchronously freed during the geometry
- // fetch.
- EXPECT_TRUE(freed) << "grabber delegate was never invoked synchronously";
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ connection->DispatchAll();
+ return freed;
+ })) << "grabber delegate was never invoked";
connection->DestroyWindow({new_parent});
}
@@ -712,9 +714,10 @@
// Step 7: Dispatch the event to widget. This will trigger DispatchEvent.
DispatchXEvent(xi_event, widget);
- // If we got here without ASAN/crash tripping, verify the window was indeed
- // freed.
- EXPECT_TRUE(freed) << "delegate was never invoked synchronously";
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ connection->DispatchAll();
+ return freed;
+ })) << "delegate was never invoked";
connection->DestroyWindow({new_parent});
}
Original Bug Report
Potential Use-After-Free in WindowTreeHost::OnDisplayMetricsChanged
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential Use-After-Free (UAF) vulnerability exists in the browser process on Linux/X11 platforms during display metric updates. Under Ozone/X11, a call to GetDisplayId() can synchronously dispatch X11 events and lead to the reentrant destruction of the WindowTreeHost object. Subsequent evaluations within the same expression and calls to the associated compositor access and write to freed memory.
Affected files:
ui/aura/window_tree_host.ccui/ozone/platform/x11/x11_window.ccui/compositor/compositor.cc
Estimated timestamp from git blame: 2025-03-06
Root Cause Analysis
In ui/aura/window_tree_host.cc, the method WindowTreeHost::OnDisplayMetricsChanged is responsible for handling display updates. On the DISPLAY_METRIC_COLOR_SPACE branch, it evaluates GetDisplayId() inside a short-circuiting if statement:
// ui/aura/window_tree_host.cc:648-653
void WindowTreeHost::OnDisplayMetricsChanged(const display::Display& display,
uint32_t metrics) {
if (metrics & DisplayObserver::DISPLAY_METRIC_COLOR_SPACE && compositor_ &&
display.id() == GetDisplayId() && !display_color_spaces_) {
compositor_->SetDisplayColorSpaces(display.GetColorSpaces());
}
On Linux/X11, GetDisplayId() delegates through ScreenOzone to X11ScreenOzone::GetDisplayForAcceleratedWidget, which retrieves window bounds (ui/ozone/platform/x11/x11_screen_ozone.cc:137-138):
return GetDisplayForRect(GetAllDisplays(), window->GetBoundsInPixels(), true);
X11Window::GetBoundsInPixels() invokes geometry_cache_->GetBoundsPx() (ui/ozone/platform/x11/x11_window.cc:620). As documented in the codebase, if the geometry cache is un-ready or has pending requests, querying bounds synchronously dispatches pending X server events using parent_future_.DispatchNow():
// ui/ozone/platform/x11/x11_window.cc:614-617
// Note: geometry_cache_->GetBoundsPx() can dispatch pending X server events
// synchronously, which can trigger window destruction and invalidate `this`.
If the dispatched events trigger window closure (e.g. via layout/widget bounds observers), the WindowTreeHost object and its associated ui::Compositor are synchronously deleted.
When control returns from GetDisplayId() back to the expression chain in OnDisplayMetricsChanged, this has already been freed:
- Evaluating
!display_color_spaces_reads from the freedWindowTreeHostinstance (UAF read). - The statement
compositor_->SetDisplayColorSpaces(...)reads thecompositor_unique pointer from the freed host memory, and invokesSetDisplayColorSpaceson the already-deletedCompositorobject. - Inside
Compositor::SetDisplayColorSpaces(ui/compositor/compositor.cc:553), updatingdisplay_color_spaces_results in a heap write to the freedCompositorslot. This is followed by a dereference ofhost_(ui/compositor/compositor.cc:555), allowing potential control-flow hijacking in the unsandboxed browser process.
Potential Attack Scenario
Note: These are suggested/potential steps of an exploit flow. Our tooling agent does not have the ability to run code or compile a working proof-of-concept.
- A compromised GPU process or local same-display client issues a
ReparentWindowrequest against a browser window to put the parentGeometryCacheinto an un-Readystate with pending futures. - The attacker triggers a display color space change by modifying the root-window
_ICC_PROFILEproperty and altering_NET_WORKAREAproperties to dispatch a metric notification. - The browser receives the notification and runs
WindowTreeHost::OnDisplayMetricsChangedwithDISPLAY_METRIC_COLOR_SPACE. - Mid-expression evaluation, the synchronous bounds-query forces event dispatch, which triggers a bounds-observer that synchronously closes/deletes the
WindowTreeHostand itsCompositor. - Upon return, the browser process accesses the freed
WindowTreeHostandCompositormemory, yielding a Use-After-Free heap write.
Suggested Fix
Guard the metric changes inside WindowTreeHost::OnDisplayMetricsChanged using a base::WeakPtr of the host before performing queries that can cause reentrant deletion, and check its validity before accessing members or invoking the compositor:
void WindowTreeHost::OnDisplayMetricsChanged(const display::Display& display,
uint32_t metrics) {
if (metrics & DisplayObserver::DISPLAY_METRIC_COLOR_SPACE && compositor_) {
auto weak_this = GetWeakPtr();
const int64_t display_id = GetDisplayId();
if (!weak_this)
return;
if (display.id() == display_id && !display_color_spaces_) {
compositor_->SetDisplayColorSpaces(display.GetColorSpaces());
}
}
// Apply similar WeakPtr guards to subsequent metric branches if necessary.
}
Evaluated with Chrome root at commit: 75203b87cbf6681eb7c7dda8e1d0bf781538c76a
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.