Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Aura
DescriptionUse after free in Aura
ComponentAura
Sub-componentOzone/X11
Bug ClassUAF
Action tierwatch — corruption primitive but gated (local) — not default-reachable — factors set by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed
Reviewer notes: Citations verified. Fix commit lists 524824288 under Fixed. WindowTreeHost::OnDisplayMetricsChanged site is report-only (correctly marked inferred). One overstatement: post-fix GetBoundsPx is not side-effect-free (resets/creates parent_).
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker524824288
Fix commitafcbd943b2ea (chromium/src) +101/-51
Related commitsfc4613ff876d companion (removes workaround guards) · chromium/src
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-06-17 (per vendor advisory)
Fix landed2026-07-21 (public git)
Disclosed2026-08-06
Patch gap16 days — fix public in source before the advisory

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 with OnResponse() (ui/gfx/x/future.h:88-150).
`FutureImpl::DispatchNow`
A call that blocks in Wait() and then runs ProcessResponse(), 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, and NotifyGeometryChanged() reports changes through bounds_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 in ui/ozone/platform/x11/x11_window.cc.

Root Cause Analysis

Re-analyzed on 2026-10-03 against the actual fix commit 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.

Key insight
A bounds getter (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

  1. Make the geometry cache un-ready A window’s GeometryCache needs to wait on a reply, for example after a ReparentNotify replaces parent_ with a fresh cache that has pending QueryTree/GetGeometry futures (ui/gfx/x/geometry_cache.cc:150-153, ui/gfx/x/geometry_cache.cc:85-90).
  2. Reach a bounds query from a host method Per the reporter, a display color-space metric change runs WindowTreeHost::OnDisplayMetricsChanged, which calls GetDisplayId() and from there the X11 window’s GetBoundsInPixels() (inferred; only the reporter’s text shows this chain).
  3. Synchronous callback dispatch GetBoundsPx() calls DispatchNow() (ui/gfx/x/geometry_cache.cc:39, ui/gfx/x/geometry_cache.cc:46, or through the parent at ui/gfx/x/geometry_cache.cc:56), which leads to bounds_changed_callback_.Run (ui/gfx/x/geometry_cache.cc:136).
  4. Re-entrant teardown A bounds-changed observer closes the widget, destroying the X11Window, the WindowTreeHost and its Compositor (inferred; the companion’s removed tests model this with window.reset() inside OnBoundsChanged).
  5. Use of freed host Control returns to the calling expression, which reads display_color_spaces_ and calls compositor_->SetDisplayColorSpaces(...) on freed memory (reporter’s claim; inferred from the material).

Impact Assessment

This is a use after free of 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

FunctionChangeNotes
if
ui/gfx/x/future.h
modified
if
ui/gfx/x/geometry_cache.cc
modified
TEST
ui/gfx/x/geometry_cache_unittest.cc
modified
EXPECT_TRUE
ui/ozone/platform/x11/x11_window_ozone_unittest.cc
modified

Files Changed

  • ui/gfx/x/future.cc
  • ui/gfx/x/future.h
  • ui/gfx/x/geometry_cache.cc
  • ui/gfx/x/geometry_cache_unittest.cc
  • ui/ozone/platform/x11/x11_window_ozone_unittest.cc

Audit Directions

  • Getters that dispatch
    Look for “read” APIs that call DispatchNow(), ProcessResponse() or run nested event loops; any caller holding this across them can be freed partway through an expression.
  • Point-fix guards vs. root cause
    The several weak_this guards that were added and then reverted show that per-caller patches miss call sites such as WindowTreeHost::OnDisplayMetricsChanged. Check other Future::DispatchNow() users for the same re-entrancy.
  • Peek semantics
    Check that FutureImpl::Peek() setting *error = nullptr and leaving have_parent_ unset cannot cause stale or inconsistent geometry once the queued OnQueryTreeResponse/OnGetGeometryResponse callbacks later run.
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});
 }
Loading diff…

Regression Test / PoC

shipped with the fix
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});
 }
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • ui/ozone/platform/x11/x11_window.cc
  • ui/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:

  1. Evaluating !display_color_spaces_ reads from the freed WindowTreeHost instance (UAF read).
  2. The statement compositor_->SetDisplayColorSpaces(...) reads the compositor_ unique pointer from the freed host memory, and invokes SetDisplayColorSpaces on the already-deleted Compositor object.
  3. Inside Compositor::SetDisplayColorSpaces (ui/compositor/compositor.cc:553), updating display_color_spaces_ results in a heap write to the freed Compositor slot. This is followed by a dereference of host_ (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.

  1. A compromised GPU process or local same-display client issues a ReparentWindow request against a browser window to put the parent GeometryCache into an un-Ready state with pending futures.
  2. The attacker triggers a display color space change by modifying the root-window _ICC_PROFILE property and altering _NET_WORKAREA properties to dispatch a metric notification.
  3. The browser receives the notification and runs WindowTreeHost::OnDisplayMetricsChanged with DISPLAY_METRIC_COLOR_SPACE.
  4. Mid-expression evaluation, the synchronous bounds-query forces event dispatch, which triggers a bounds-observer that synchronously closes/deletes the WindowTreeHost and its Compositor.
  5. Upon return, the browser process accesses the freed WindowTreeHost and Compositor memory, 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.

View on issue tracker