Medium watch CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Compositing
DescriptionUse after free in Compositing
ComponentCompositing
Sub-componentCc
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker501604761
Fix commit424ab5d800ff (chromium/src) +157/-15
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-11 (per vendor advisory)
Fix landed2026-07-14 (public git)
Disclosed2026-08-25
Patch gap42 days — fix public in source before the advisory

Background

`TileManager`
the cc component that decides which tiles of PictureLayerImpl tilings get raster memory, schedules raster work, and evicts tile resources when over budget (cc/tiles/tile_manager.cc).
`RasterTilePriorityQueue` / `EvictionTilePriorityQueue`
iterator-style queues built by the client (client_->BuildEvictionQueue()) that hold raw pointers into live PictureLayerTilings and Tiles while they are walked; the PrioritizedTiles they yield are only valid while those tilings exist.
`NotifyTileStateChanged(tile, update_damage, set_needs_redraw)`
the TileManagerClient callback (implemented by the layer tree host impl) that reports a tile’s draw state change; with set_needs_redraw=true it asks for a redraw, which can drive the scheduler.
`ActivateSyncTree()`
the scheduler action that swaps the pending tree into the active tree, which can remove or replace tilings referenced by in-flight priority queues.

Root Cause Analysis

While TileManager walked an eviction or raster priority queue, it notified the client of each tile state change with set_needs_redraw=true: in FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw() (used by every eviction loop) and in the solid-color path inside the gpu-memory assignment code (the commit message names it AssignGpuMemoryToTiles()). The commit message explains that, when reached from the posted CheckIfMoreTilesNeedToBePrepared task, this redraw request may re-enter Scheduler::ProcessScheduledActions() and run ActivateSyncTree(), which can remove tilings that the live queue iterators and the collected PrioritizedTiles still point at. Further iteration then reads freed PictureLayerTiling/Tile memory, a use-after-free. The violated invariant is that nothing may mutate the tiling set while a priority queue built from it is being consumed.

The fix passes set_needs_redraw=false for every per-tile notification, makes FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw() return tile->required_for_draw(), collects that result through new bool* freed_required_for_draw_tile out-parameters and a new required_for_draw_tile_state_changed field on the work-to-schedule struct, and issues one client_->SetNeedsRedraw() only after the queues are reset or the scheduled work has been handed to ScheduleTasks(). The commit describes this as the same deferral pattern already used by MarkTilesOutOfMemory().

Key insight
TileManager called back into code that can re-enter the scheduler and destroy tilings while it was still iterating raw-pointer priority queues over those tilings. The fix removes the per-tile redraw requests from inside the loops and issues one deferred SetNeedsRedraw() after the queues and their PrioritizedTiles are no longer used.

Attack Path

  1. Build compositor state with required-for-draw tiles Web content creates layers whose tiles are rastered and marked required_for_draw() in the renderer compositor (inferred; the advisory gives no trigger details).
  2. Force eviction or solid-color handling during tile assignment Memory pressure or budget changes make the gpu-memory assignment path, TrimPrepaintTiles(), or the idle memory reduction path free tile resources, or a tile is found to be solid color, while a priority queue is being iterated.
  3. Re-entrant redraw from inside the loop Before the patch each freed or solid-color tile called NotifyTileStateChanged(..., /*set_needs_redraw=*/true). Per the commit message, when this runs from the CheckIfMoreTilesNeedToBePrepared task it can reach Scheduler::ProcessScheduledActions() and ActivateSyncTree(), which removes tilings.
  4. Stale iterator use The loop then continues with eviction_priority_queue->Pop()/Top() or later uses the collected PrioritizedTiles, touching freed tiling or tile memory (UAF). The added unit test models this with a client that calls RemoveAllTilings() from the notification.
  5. Memory corruption in the compositor An attacker who can control the freed memory could turn this into memory corruption (inferred); the patch shows only the lifetime bug.

Impact Assessment

The bug is a use-after-free in the cc compositor’s tile management. For web content this code runs in the renderer process, which is sandboxed, and the same cc code also backs browser-side compositors. Reaching it depends on a timing-sensitive interaction between tile eviction and scheduler re-entrancy, and memory corruption from that point could lead to code execution in that process (inferred). The advisory gives only “Use after free in Compositing”. It does not state a severity or whether the bug was exploited in the wild.

Changed Functions

FunctionChangeNotes
if
cc/tiles/tile_manager.cc
modified
TileManagerDeferredRedrawTest
cc/tiles/tile_manager_unittest.cc
modified
ReentrantHostImpl
cc/tiles/tile_manager_unittest.cc
modified
if
cc/tiles/tile_manager_unittest.cc
modified
TEST_F
cc/tiles/tile_manager_unittest.cc
modified
TileManagerOcclusionTest
cc/tiles/tile_manager_unittest.cc
modified

Files Changed

  • cc/tiles/tile_manager.h
  • cc/tiles/tile_manager_unittest.cc
  • cc/tiles/tile_manager.cc

Audit Directions

  • Client callbacks during raw-pointer iteration
    Check other cc loops that hold RasterTilePriorityQueue, EvictionTilePriorityQueue, or PrioritizedTile vectors and call TileManagerClient methods (NotifyTileStateChanged, SetNeedsRedraw, NotifyReadyToActivate, NotifyReadyToDraw) that could re-enter the scheduler.
  • Scheduler re-entrancy from posted tasks
    Look at tasks such as CheckIfMoreTilesNeedToBePrepared and DidFinishRunningTileTasks* for paths where a notification can synchronously run ProcessScheduledActions() and activate or destroy trees while the caller’s stack still holds tiling pointers.
  • Inconsistent deferral patterns
    Where one path already defers side effects (here MarkTilesOutOfMemory()), compare the sibling paths that do the same state change for the immediate-callback version that was missed.
From 424ab5d800ff8f1d3c2fc776f9ef00862fc54474 Mon Sep 17 00:00:00 2001
From: Zhenyao Mo <zmo@chromium.org>
Date: Tue, 14 Jul 2026 13:20:19 -0700
Subject: [PATCH] cc: Defer SetNeedsRedraw while iterating tile priority queues

AssignGpuMemoryToTiles() and the eviction helpers it uses notify the
client of each tile's state change with set_needs_redraw=true while a
Raster/EvictionTilePriorityQueue is still being iterated. From the
posted CheckIfMoreTilesNeedToBePrepared task this may re-enter
Scheduler::ProcessScheduledActions() and trigger ActivateSyncTree(),
which can remove tilings still referenced by the live priority-queue
iterators.

Apply the same defer pattern used by MarkTilesOutOfMemory(): pass
set_needs_redraw=false for each tile, accumulate whether any
required-for-draw tile changed state, and request a single redraw once
the queues and the PrioritizedTiles derived from them have been fully
consumed. This covers the eviction loops and the solid-color path in
AssignGpuMemoryToTiles(), as well as TrimPrepaintTiles() and
ReduceTileMemoryWhenIdle() which share the same helpers.

Bug: 501604761
Change-Id: I4b78af9286a1c8f1a0cda2f30f2249c8ab6dba44
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8088758
Commit-Queue: Zhenyao Mo <zmo@chromium.org>
Reviewed-by: vikas soni <vikassoni@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1662124}
---
diff --git a/cc/tiles/tile_manager.cc b/cc/tiles/tile_manager.cc
index 890ab156..27f120ed 100644
--- a/cc/tiles/tile_manager.cc
+++ b/cc/tiles/tile_manager.cc
@@ -508,8 +508,13 @@
   // Note: we don't need to flush anything here, even though this is a case
   // where frames are not being produced. The resource pool will itself issue a
   // flush after a few seconds when a resource becomes unused.
+  bool freed_required_for_draw_tile = false;
   FreeTileResourcesWithLowerPriorityUntilUsageIsWithinLimit(
-      nullptr, limit, kVisiblePriority, &usage);
+      nullptr, limit, kVisiblePriority, &usage, &freed_required_for_draw_tile);
+  if (freed_required_for_draw_tile) {
+    client_->SetNeedsRedraw(/*animation_only=*/false,
+                            /*skip_if_inside_draw=*/true);
+  }
 }
 
 void TileManager::TrimPrepaintTiles() {
@@ -518,6 +523,7 @@
   std::unique_ptr<EvictionTilePriorityQueue> eviction_priority_queue =
       client_->BuildEvictionQueue();
   bool has_eligible_used_tiles = false;
+  bool freed_required_for_draw_tile = false;
   for (; !eviction_priority_queue->IsEmpty(); eviction_priority_queue->Pop()) {
     const auto& prioritized_tile = eviction_priority_queue->Top();
     Tile* tile = prioritized_tile.tile();
@@ -546,7 +552,8 @@
       // PictureLayerTiling::ComputePriorityForTile() sets the bin to EVENTUALLY
       // regardless (because the client doesn't have valid priorities).
       // We don't want to keep these tiles, so no DCHECK() or exclusion here.
-      FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw(tile);
+      freed_required_for_draw_tile |=
+          FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw(tile);
     } else {
       // Tile has been used recently, reset this so that if it's not used until
       // the next reclaim task, then we know it has been at least
@@ -556,6 +563,11 @@
       has_eligible_used_tiles = true;
     }
   }
+  eviction_priority_queue.reset();
+  if (freed_required_for_draw_tile) {
+    client_->SetNeedsRedraw(/*animation_only=*/false,
+                            /*skip_if_inside_draw=*/true);
+  }
 
   // Reschedule the task, since there are tiles that would be eligible to evict
   // if they were old enough. Note that we don't choose the smallest delay
@@ -717,9 +729,22 @@
       !prioritized_work.tiles_to_raster.empty() &&
       prioritized_work.tiles_to_raster.front().tile()->required_for_draw());
 
+  const bool required_for_draw_tile_state_changed =
+      prioritized_work.required_for_draw_tile_state_changed;
+
   // Schedule tile tasks.
   ScheduleTasks(std::move(prioritized_work));
 
+  // If we trigger SetNeedsRedraw() while iterating the priority queues, we may
+  // end up triggering Scheduler::ProcessScheduledActions(), which is
+  // inefficient and may in turn trigger ActivateSyncTree() and other actions
+  // that remove tiles still referenced by the queues. Defer the redraw until
+  // the queues and the prioritized tiles they produced have been consumed.
+  if (required_for_draw_tile_state_changed) {
+    client_->SetNeedsRedraw(/*animation_only=*/false,
+                            /*skip_if_inside_draw=*/true);
+  }
+
   TRACE_EVENT_INSTANT("cc", "DidPrepareTiles", "state", BasicStateAsValue());
   return true;
 }
@@ -823,7 +848,8 @@
 TileManager::FreeTileResourcesUntilUsageIsWithinLimit(
     std::unique_ptr<EvictionTilePriorityQueue> eviction_priority_queue,
     const MemoryUsage& limit,
-    MemoryUsage* usage) {
+    MemoryUsage* usage,
+    bool* freed_required_for_draw_tile) {
   while (usage->Exceeds(limit)) {
     if (!eviction_priority_queue) {
       eviction_priority_queue = client_->BuildEvictionQueue();
@@ -833,7 +859,8 @@
 
     Tile* tile = eviction_priority_queue->Top().tile();
     *usage -= MemoryUsage::FromTile(tile);
-    FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw(tile);
+    *freed_required_for_draw_tile |=
+        FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw(tile);
     eviction_priority_queue->Pop();
   }
   return eviction_priority_queue;
@@ -844,7 +871,8 @@
     std::unique_ptr<EvictionTilePriorityQueue> eviction_priority_queue,
     const MemoryUsage& limit,
     const TilePriority& other_priority,
-    MemoryUsage* usage) {
+    MemoryUsage* usage,
+    bool* freed_required_for_draw_tile) {
   while (usage->Exceeds(limit)) {
     if (!eviction_priority_queue) {
       eviction_priority_queue = client_->BuildEvictionQueue();
@@ -858,7 +886,8 @@
 
     Tile* tile = prioritized_tile.tile();
     *usage -= MemoryUsage::FromTile(tile);
-    FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw(tile);
+    *freed_required_for_draw_tile |=
+        FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw(tile);
     eviction_priority_queue->Pop();
   }
   return eviction_priority_queue;
@@ -940,7 +969,9 @@
       if (is_solid_color) {
         tile->draw_info().set_solid_color(color);
         client_->NotifyTileStateChanged(tile, /*update_damage=*/true,
-                                        /*set_needs_redraw=*/true);
+                                        /*set_needs_redraw=*/false);
+        work_to_schedule.required_for_draw_tile_state_changed |=
+            tile->required_for_draw();
         continue;
       }
     }
@@ -1003,7 +1034,8 @@
     eviction_priority_queue =
         FreeTileResourcesWithLowerPriorityUntilUsageIsWithinLimit(
             std::move(eviction_priority_queue), scheduled_tile_memory_limit,
-            priority, &memory_usage);
+            priority, &memory_usage,
+            &work_to_schedule.required_for_draw_tile_state_changed);
     bool memory_usage_is_within_limit =
         !memory_usage.Exceeds(scheduled_tile_memory_limit);
 
@@ -1056,7 +1088,8 @@
   // didn't reduce memory. This ensures that we always release as many resources
   // as possible to stay within the memory limit.
   eviction_priority_queue = FreeTileResourcesUntilUsageIsWithinLimit(
-      std::move(eviction_priority_queue), hard_memory_limit, &memory_usage);
+      std::move(eviction_priority_queue), hard_memory_limit, &memory_usage,
+      &work_to_schedule.required_for_draw_tile_state_changed);
 
   // At this point, if we ran out of memory when allocating resources and we
   // couldn't go past even the NOW bin, this means we have evicted resources
@@ -1168,13 +1201,17 @@
   }
 }
 
-void TileManager::FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw(
+bool TileManager::FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw(
     Tile* tile) {
   TRACE_EVENT0("viz", __PRETTY_FUNCTION__);
   bool was_ready_to_draw = tile->draw_info().IsReadyToDraw();
   FreeResourcesForTile(tile);
+  // Do not request a redraw here; this is always called while a priority queue
+  // holding raw tile/tiling pointers is being iterated. The caller is
+  // responsible for requesting a single redraw once iteration is complete.
   client_->NotifyTileStateChanged(tile, /*update_damage=*/was_ready_to_draw,
-                                  /*set_needs_redraw=*/true);
+                                  /*set_needs_redraw=*/false);
+  return tile->required_for_draw();
 }
 
 void TileManager::PartitionImagesForCheckering(
@@ -1916,13 +1953,25 @@
       !work_to_schedule.tiles_to_raster.empty() &&
       work_to_schedule.tiles_to_raster.front().tile()->required_for_draw());
 
+  const bool required_for_draw_tile_state_changed =
+      work_to_schedule.required_for_draw_tile_state_changed;
+
   // |tiles_that_need_to_be_rasterized| will be empty when we reach a
   // steady memory state. Keep scheduling tasks until we reach this state.
   if (!work_to_schedule.tiles_to_raster.empty()) {
     ScheduleTasks(std::move(work_to_schedule));
+    if (required_for_draw_tile_state_changed) {
+      client_->SetNeedsRedraw(/*animation_only=*/false,
+                              /*skip_if_inside_draw=*/true);
+    }
     return;
   }
 
+  if (required_for_draw_tile_state_changed) {
+    client_->SetNeedsRedraw(/*animation_only=*/false,
+                            /*skip_if_inside_draw=*/true);
+  }
+
   // If we're not in SMOOTHNESS_TAKES_PRIORITY  mode, we should unlock all
   // images since we're technically going idle here at least for this frame.
   if (global_state_.tree_priority != SMOOTHNESS_TAKES_PRIORITY) {
diff --git a/cc/tiles/tile_manager.h b/cc/tiles/tile_manager.h
index 008d4c0..c1e6fa09 100644
--- a/cc/tiles/tile_manager.h
+++ b/cc/tiles/tile_manager.h
@@ -296,6 +296,11 @@
     // are not necessarily associated with any tile.
     std::vector<DrawImage> extra_prepaint_images;
     CheckerImageTracker::ImageDecodeQueue checker_image_decode_queue;
+    // True if a tile that is required for draw had its state changed (e.g.
+    // resource freed or solid color set) while assigning gpu memory. The
+    // caller should request a redraw once it has finished using the tile and
+    // tiling pointers held by this struct.
+    bool required_for_draw_tile_state_changed = false;
   };
 
   // Frees the resources of all occluded tiles.
@@ -309,7 +314,9 @@
 
   // True if tile resources are present and freed.
   void FreeResourcesForTile(Tile* tile);
-  void FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw(Tile* tile);
+  // Returns true if `tile` is required for draw, in which case the caller
+  // should request a redraw once it has finished iterating tiles.
+  bool FreeResourcesForTileAndNotifyClientIfTileWasReadyToDraw(Tile* tile);
   scoped_refptr<TileTask> CreateRasterTask(
       const PrioritizedTile& prioritized_tile,
       const TargetColorParams& target_color_params,
@@ -319,13 +326,15 @@
   FreeTileResourcesUntilUsageIsWithinLimit(
       std::unique_ptr<EvictionTilePriorityQueue> eviction_priority_queue,
       const MemoryUsage& limit,
-      MemoryUsage* usage);
+      MemoryUsage* usage,
+      bool* freed_required_for_draw_tile);
   std::unique_ptr<EvictionTilePriorityQueue>
   FreeTileResourcesWithLowerPriorityUntilUsageIsWithinLimit(
       std::unique_ptr<EvictionTilePriorityQueue> eviction_priority_queue,
       const MemoryUsage& limit,
-      const TilePriority& oother_priority,
-      MemoryUsage* usage);
+      const TilePriority& other_priority,
+      MemoryUsage* usage,
+      bool* freed_required_for_draw_tile);
   bool TilePriorityViolatesMemoryPolicy(const TilePriority& priority);
   bool AreRequiredTilesReadyToDraw(RasterTilePriorityQueue::Type type) const;
   void CheckIfMoreTilesNeedToBePrepared();
diff --git a/cc/tiles/tile_manager_unittest.cc b/cc/tiles/tile_manager_unittest.cc
index eba043b..b7eaf7f9 100644
--- a/cc/tiles/tile_manager_unittest.cc
+++ b/cc/tiles/tile_manager_unittest.cc
@@ -46,6 +46,7 @@
 #include "cc/test/test_task_graph_runner.h"
 #include "cc/test/test_tile_priorities.h"
 #include "cc/tiles/eviction_tile_priority_queue.h"
+#include "cc/tiles/picture_layer_tiling_set.h"
 #include "cc/tiles/raster_tile_priority_queue.h"
 #include "cc/tiles/tile.h"
 #include "cc/tiles/tile_priority.h"
@@ -1897,6 +1898,89 @@
   }
 }
 
+class TileManagerDeferredRedrawTest : public TestLayerTreeHostBase {
+ public:
+  // Host impl that simulates a client which removes tilings synchronously when
+  // notified of a tile state change with `set_needs_redraw` set. TileManager
+  // must defer SetNeedsRedraw() until it has finished iterating its priority
+  // queues so that it is safe for the client to do this.
+  class ReentrantHostImpl : public FakeLayerTreeHostImpl {
+   public:
+    using FakeLayerTreeHostImpl::FakeLayerTreeHostImpl;
+
+    void NotifyTileStateChanged(const Tile* tile,
+                                bool update_damage,
+                                bool set_needs_redraw) override {
+      if (set_needs_redraw && tilings_to_remove_) {
+        ++notify_with_redraw_count_;
+        PictureLayerTilingSet* tilings = tilings_to_remove_;
+        tilings_to_remove_ = nullptr;
+        tilings->RemoveAllTilings();
+      }
+    }
+
+    void SetNeedsRedraw(bool animation_only,
+                        bool skip_if_inside_draw) override {
+      ++set_needs_redraw_count_;
+    }
+
+    void set_tilings_to_remove(PictureLayerTilingSet* tilings) {
+      tilings_to_remove_ = tilings;
+    }
+    int notify_with_redraw_count() const { return notify_with_redraw_count_; }
+    int set_needs_redraw_count() const { return set_needs_redraw_count_; }
+
+   private:
+    raw_ptr<PictureLayerTilingSet> tilings_to_remove_ = nullptr;
+    int notify_with_redraw_count_ = 0;
+    int set_needs_redraw_count_ = 0;
+  };
+
+  std::unique_ptr<FakeLayerTreeHostImpl> CreateHostImpl(
+      const LayerTreeSettings& settings,
+      TaskRunnerProvider* task_runner_provider,
+      TaskGraphRunner* task_graph_runner) override {
+    return std::make_unique<ReentrantHostImpl>(settings, task_runner_provider,
+                                               task_graph_runner);
+  }
+
+  std::unique_ptr<LayerTreeFrameSink> CreateLayerTreeFrameSink() override {
+    return FakeLayerTreeFrameSink::CreateSoftware();
+  }
+
+  ReentrantHostImpl* reentrant_host_impl() {
+    return static_cast<ReentrantHostImpl*>(host_impl());
+  }
+};
+
+TEST_F(TileManagerDeferredRedrawTest, DeferRedrawWhileFreeingTileResources) {
+  const gfx::Size layer_bounds(1000, 1000);
+  host_impl()->active_tree()->SetDeviceViewportRect(gfx::Rect(layer_bounds));
+  SetupDefaultTrees(layer_bounds);
+
+  std::vector<Tile*> active_tiles =
+      active_layer()->HighResTiling()->AllTilesForTesting();
+  ASSERT_GT(active_tiles.size(), 1u);
+  host_impl()->tile_manager()->InitializeTilesWithResourcesForTesting(
+      active_tiles);
+
+  // Reduce the memory limit so that all active tiles will be evicted on the
+  // next PrepareTiles().
+  auto global_state = host_impl()->global_tile_state();
+  global_state.hard_memory_limit_in_bytes = 0u;
+  global_state.soft_memory_limit_in_bytes = 0u;
+  reentrant_host_impl()->set_tilings_to_remove(active_layer()->tilings());
+
+  // Freeing tile resources notifies the client of each tile's state change but
+  // must defer SetNeedsRedraw() until the priority queues are no longer in use.
+  // No tile state change notification should request a redraw while resources
+  // are being freed; instead a single SetNeedsRedraw() should be issued
+  // afterwards.
+  host_impl()->tile_manager()->PrepareTiles(global_state);
+  EXPECT_EQ(0, reentrant_host_impl()->notify_with_redraw_count());
+  EXPECT_LE(1, reentrant_host_impl()->set_needs_redraw_count());
+}
+
 class TileManagerOcclusionTest : public TileManagerTest {
  public:
   LayerTreeSettings CreateSettings() override {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/cc/tiles/tile_manager_unittest.cc b/cc/tiles/tile_manager_unittest.cc
index eba043b..b7eaf7f9 100644
--- a/cc/tiles/tile_manager_unittest.cc
+++ b/cc/tiles/tile_manager_unittest.cc
@@ -46,6 +46,7 @@
 #include "cc/test/test_task_graph_runner.h"
 #include "cc/test/test_tile_priorities.h"
 #include "cc/tiles/eviction_tile_priority_queue.h"
+#include "cc/tiles/picture_layer_tiling_set.h"
 #include "cc/tiles/raster_tile_priority_queue.h"
 #include "cc/tiles/tile.h"
 #include "cc/tiles/tile_priority.h"
@@ -1897,6 +1898,89 @@
   }
 }
 
+class TileManagerDeferredRedrawTest : public TestLayerTreeHostBase {
+ public:
+  // Host impl that simulates a client which removes tilings synchronously when
+  // notified of a tile state change with `set_needs_redraw` set. TileManager
+  // must defer SetNeedsRedraw() until it has finished iterating its priority
+  // queues so that it is safe for the client to do this.
+  class ReentrantHostImpl : public FakeLayerTreeHostImpl {
+   public:
+    using FakeLayerTreeHostImpl::FakeLayerTreeHostImpl;
+
+    void NotifyTileStateChanged(const Tile* tile,
+                                bool update_damage,
+                                bool set_needs_redraw) override {
+      if (set_needs_redraw && tilings_to_remove_) {
+        ++notify_with_redraw_count_;
+        PictureLayerTilingSet* tilings = tilings_to_remove_;
+        tilings_to_remove_ = nullptr;
+        tilings->RemoveAllTilings();
+      }
+    }
+
+    void SetNeedsRedraw(bool animation_only,
+                        bool skip_if_inside_draw) override {
+      ++set_needs_redraw_count_;
+    }
+
+    void set_tilings_to_remove(PictureLayerTilingSet* tilings) {
+      tilings_to_remove_ = tilings;
+    }
+    int notify_with_redraw_count() const { return notify_with_redraw_count_; }
+    int set_needs_redraw_count() const { return set_needs_redraw_count_; }
+
+   private:
+    raw_ptr<PictureLayerTilingSet> tilings_to_remove_ = nullptr;
+    int notify_with_redraw_count_ = 0;
+    int set_needs_redraw_count_ = 0;
+  };
+
+  std::unique_ptr<FakeLayerTreeHostImpl> CreateHostImpl(
+      const LayerTreeSettings& settings,
+      TaskRunnerProvider* task_runner_provider,
+      TaskGraphRunner* task_graph_runner) override {
+    return std::make_unique<ReentrantHostImpl>(settings, task_runner_provider,
+                                               task_graph_runner);
+  }
+
+  std::unique_ptr<LayerTreeFrameSink> CreateLayerTreeFrameSink() override {
+    return FakeLayerTreeFrameSink::CreateSoftware();
+  }
+
+  ReentrantHostImpl* reentrant_host_impl() {
+    return static_cast<ReentrantHostImpl*>(host_impl());
+  }
+};
+
+TEST_F(TileManagerDeferredRedrawTest, DeferRedrawWhileFreeingTileResources) {
+  const gfx::Size layer_bounds(1000, 1000);
+  host_impl()->active_tree()->SetDeviceViewportRect(gfx::Rect(layer_bounds));
+  SetupDefaultTrees(layer_bounds);
+
+  std::vector<Tile*> active_tiles =
+      active_layer()->HighResTiling()->AllTilesForTesting();
+  ASSERT_GT(active_tiles.size(), 1u);
+  host_impl()->tile_manager()->InitializeTilesWithResourcesForTesting(
+      active_tiles);
+
+  // Reduce the memory limit so that all active tiles will be evicted on the
+  // next PrepareTiles().
+  auto global_state = host_impl()->global_tile_state();
+  global_state.hard_memory_limit_in_bytes = 0u;
+  global_state.soft_memory_limit_in_bytes = 0u;
+  reentrant_host_impl()->set_tilings_to_remove(active_layer()->tilings());
+
+  // Freeing tile resources notifies the client of each tile's state change but
+  // must defer SetNeedsRedraw() until the priority queues are no longer in use.
+  // No tile state change notification should request a redraw while resources
+  // are being freed; instead a single SetNeedsRedraw() should be issued
+  // afterwards.
+  host_impl()->tile_manager()->PrepareTiles(global_state);
+  EXPECT_EQ(0, reentrant_host_impl()->notify_with_redraw_count());
+  EXPECT_LE(1, reentrant_host_impl()->set_needs_redraw_count());
+}
+
 class TileManagerOcclusionTest : public TileManagerTest {
  public:
   LayerTreeSettings CreateSettings() override {
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential UAF in TileManager via synchronous tree activation during queue iteration

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 without the Chrome Security team.

Overview: When TileManager processes tiles in a posted task, state changes can trigger synchronous Scheduler actions. This can cause the active sync tree to activate, destroying tiling objects that are currently referenced by active priority queues. Because these queues use RAW_PTR_EXCLUSION for performance, dereferencing them subsequently leads to a potential Use-After-Free.

Affected files:

  • cc/tiles/tile_manager.cc
  • cc/tiles/tiling_set_eviction_queue.h
  • cc/tiles/tiling_set_raster_queue_all.h
  • cc/tiles/prioritized_tile.h
  • cc/scheduler/scheduler.cc
  • cc/tiles/picture_layer_tiling_set.cc

Estimated timestamp from git blame: 2025-07-11

Technical Details

There is a potential Use-After-Free (UAF) vulnerability in cc::TileManager caused by re-entrant scheduler actions destroying objects that are currently being iterated over.

TileManager::CheckIfMoreTilesNeedToBePrepared is executed via more_tiles_need_prepare_check_notifier_, which is a UniqueNotifier that posts a task to the compositor thread. Because this runs as a top-level posted task rather than inside a scheduler frame cycle, the Scheduler re-entrancy guards (inside_process_scheduled_actions_ and inside_scheduled_action_) are false.

When CheckIfMoreTilesNeedToBePrepared executes, it calls AssignGpuMemoryToTiles(), which constructs RasterTilePriorityQueue and EvictionTilePriorityQueue objects to manage memory. These queues iterate over PictureLayerTiling and Tile objects. For performance reasons, the internal iterators of these queues (e.g., TilingIterator in cc/tiles/tiling_set_raster_queue_all.h) store raw pointers to the tilings explicitly marked with RAW_PTR_EXCLUSION, bypassing MiraclePtr protections.

During iteration in AssignGpuMemoryToTiles() (or its eviction helpers), the code may determine that a tile is a solid color, or that it needs to be evicted. When this happens, it calls client_->NotifyTileStateChanged(..., set_needs_redraw=true).

The set_needs_redraw=true parameter propagates through LayerTreeHostImpl and ProxyImpl to Scheduler::SetNeedsRedraw(), which synchronously executes ProcessScheduledActions(). Because the re-entrancy guards are currently false, the state machine evaluates available actions. If the pending tree is ready, it may select Action::ACTIVATE_SYNC_TREE.

Executing ACTIVATE_SYNC_TREE causes PictureLayerTilingSet::UpdateTilingsToCurrentRasterSourceForActivation to synchronize tilings, which often results in the deletion of the old PictureLayerTiling objects and their owned Tile objects. When the call stack unwinds back to the loop in AssignGpuMemoryToTiles(), the loop continues and calls raster_priority_queue->Pop(). This method attempts to advance the iterator by dereferencing the RAW_PTR_EXCLUSION pointer (tiling_->TileAt(...)), resulting in a Use-After-Free on the destroyed PictureLayerTiling.

Interestingly, this hazard is known in a different part of the file. In TileManager::MarkTilesOutOfMemory, a comment explicitly notes: “If we trigger SetNeedsRedraw() inside the loop above, we may end up triggering Scheduler::ProcessScheduledActions()… Worth, it may in turn trigger ActivateSyncTree() and other actions that remove tiles in the queue, leading to UAF.” However, the protection implemented there (deferring SetNeedsRedraw) was not applied to AssignGpuMemoryToTiles.

Potential Attacker Steps

Note: These are suggested/potential steps, as our tooling agent does not yet have the ability to run code or build a proof-of-concept.

  1. Trigger Layout/Paint Operations: The attacker creates a webpage that heavily manipulates layers (e.g., via CSS will-change: transform, animations, or inserting many solid-colored <div> elements) to push the compositor to its limits and prepare a pending tree for activation.
  2. Force Task Execution: The page activity causes more_tiles_need_prepare_check_notifier_ to be scheduled, kicking off AssignGpuMemoryToTiles in a fresh posted task.
  3. Trigger State Change: The iteration hits a solid color tile, triggering NotifyTileStateChanged and synchronously activating the sync tree, freeing the currently iterated PictureLayerTiling objects.
  4. Heap Grooming: Prior to this cycle, the attacker uses WebAssembly or JavaScript ArrayBuffers to manipulate the PartitionAlloc heap, preparing fake PictureLayerTiling or Tile objects to reclaim the freed memory.
  5. Hijack Execution: When queue->Pop() dereferences the dangling pointer, it accesses the attacker’s fake object. By providing a fake virtual table for internal task references (e.g., raster_task_), the attacker redirects execution when RunOnWorkerThread() is invoked, potentially achieving Remote Code Execution (RCE) in the renderer process.

Suggested Fix

Similar to the strategy employed in TileManager::MarkTilesOutOfMemory, TileManager::AssignGpuMemoryToTiles and its eviction helpers should defer calling SetNeedsRedraw until after the priority queues have finished processing.

Modify client_->NotifyTileStateChanged calls inside these loops to pass set_needs_redraw=false. Track a boolean flag needs_redraw during the queue iterations. Once all priority queues have been emptied and the loops have completed, call client_->SetNeedsRedraw(/*animation_only=*/false, /*skip_if_inside_draw=*/true) if the flag was set.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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