High watch CVSS 8.3 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Compositing
DescriptionUse after free in Compositing
ComponentCompositing
Sub-componentInternals>Compositing
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.
Tracker540357382
Fix commit053609913f8d (chromium/src) +105/-8
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedNgoc Hieu
Reported2026-07-29 (per vendor advisory)
Fix landed2026-08-26 (public git)
Disclosed2026-09-03
Patch gap8 days — fix public in source before the advisory

Background

`Surface` (viz)
a compositor surface in the GPU/viz service; each pending frame can declare activation dependencies on other surfaces before it activates.
`SurfaceAllocationGroup`
an object grouping surfaces that share an embed token; a Surface blocked on a dependency registers itself as a blocked embedder of the dependency’s allocation group.
`activation_dependencies_` / `blocking_allocation_groups_`
the set of surface IDs a Surface is still waiting on, and the set of raw SurfaceAllocationGroup* it has registered against as a blocked embedder.
`UpdateLastPendingReferenceAndMaybeActivate`
a SurfaceAllocationGroup method that updates its last pending reference and may synchronously activate dependent surfaces, which can re-enter Surface activation logic.

Root Cause Analysis

Surface::UpdateActivationDependencies() first cleared blocking_allocation_groups_, then ran a single loop over the new activation dependencies that, per dependency, both called group->UpdateLastPendingReferenceAndMaybeActivate(surface_id) and (if the dependency had no active frame) recorded the SurfaceAllocationGroup* in new_blocking_allocation_groups. As the commit message describes, for a surface S0 depending on S1/S2, iteration one could register S0 as a blocked embedder of S1’s group G1 and stash G1 in new_blocking_allocation_groups; iteration two could activate S2, which reentrantly activates S1, allowing G1 to be deleted (if S1 is destroyed) — yet G1 was still committed into S0’s blocking_allocation_groups_ at the end, leaving a stale/dangling SurfaceAllocationGroup* because S0 was never notified of G1’s deletion.

The fix splits the single loop into two: a first loop that calls UpdateLastPendingReferenceAndMaybeActivate for every dependency (so all reentrant activations happen while S0 is not yet registered as a blocked embedder), and a second loop that only then inspects which dependencies remain unresolved and registers blocked-embedder membership for those. This ordering guarantees S0 is never registered against a group that a reentrant activation could delete, so no stale pointer lands in blocking_allocation_groups_; the patch also promotes the removal check in OnActivationDependencyResolved from DCHECK to a CHECK_EQ(erased, 1u) to enforce that the dependency being removed was actually registered.

Key insight
The single mistake was registering a surface as a blocked embedder of an allocation group in the same loop that could reentrantly activate other surfaces and delete that group, leaving a dangling SurfaceAllocationGroup*; the fix performs all activations first, then registers blocked-embedder membership only for still-unresolved dependencies.

Attack Path

  1. Create a surface with two dependencies in reentrant groups Submit frames so that surface S0 has activation dependencies S1 (group G1) and S2 (group G2), with S1 initially lacking an active frame.
  2. Register then trigger reentrancy In the old single loop, S0 registers as a blocked embedder of G1 and stores G1 in new_blocking_allocation_groups; a later iteration activates S2, which reentrantly activates S1.
  3. Delete the group while still referenced With S1 activated and then destroyed (and its embedder reference removed), G1 is garbage-collected, but S0 still holds G1 in blocking_allocation_groups_ and was never notified of the deletion, leaving a stale raw pointer.
  4. Use the stale pointer A subsequent UpdateActivationDependencies() on S0 iterates blocking_allocation_groups_ (which wrongly still contains the freed G1), a use-after-free, matching the advisory’s use-after-free in Compositing; the regression test ReentrantSurfaceActivationStaleAllocationGroup reproduces exactly this sequence.

Impact Assessment

An attacker able to submit crafted compositor frames (e.g. from a renderer, (inferred) as the frame source) can arrange reentrant surface activation that leaves a freed SurfaceAllocationGroup* in a Surface’s blocking_allocation_groups_, yielding a use-after-free when that set is later iterated in the viz/GPU service process. The advisory reports a use-after-free in Compositing and does not state exploitation in the wild; escalation to controlled memory corruption or code execution is (inferred).

Changed Functions

FunctionChangeNotes
if
components/viz/service/surfaces/surface.cc
modified
SurfaceSynchronizationTestMayAlwaysAckOnActivation
components/viz/service/frame_sinks/surface_synchronization_unittest.cc
modified
TEST_F
components/viz/service/surfaces/surface_unittest.cc
modified

Files Changed

  • components/viz/service/frame_sinks/surface_synchronization_unittest.cc
  • components/viz/service/surfaces/surface.cc
  • components/viz/service/surfaces/surface_unittest.cc

Audit Directions

  • Register-before-reentrancy ordering
    Audit code that registers an object into a tracking set and then performs work that can synchronously call back and delete the tracked target; do the reentrant work before recording membership.
  • Raw pointers in `flat_set`s across callbacks
    Review blocking_allocation_groups_-style sets of raw (raw_ptr) pointers whose membership survives across notifications, ensuring every deletion path notifies every holder before the pointee is freed.
  • `DCHECK` guarding lifetime invariants
    Where a DCHECK asserts a container-membership/lifetime precondition, consider whether it masks a real reentrancy bug that only manifests in release builds, and whether it should be a CHECK.
From 053609913f8db7469fff46fe0170001d4123b094 Mon Sep 17 00:00:00 2001
From: kylechar <[email protected]>
Date: Wed, 26 Aug 2026 13:46:54 -0700
Subject: [PATCH] Fix re-entrancy bug in UpdateActivationDependencies()

Surface::UpdateActivationDependencies() first removes all existing
activation dependencies and clears blocking_allocation_groups_. It then
does a loop through new activation dependencies that either activates
dependent surfaces or registers the surface as a blocked embedder. If a
surface S0 had two activation dependencies, S1 and S2, it could register
as blocked embedder for S1 group G1 in the first loop iteration if S1
didn't have an active frame. It would also store G1 in
new_blocking_allocation_groups for S0. The second loop iteration could
activate S2, which could in turn active S1, which allows G1 to be
deleted if S1 is destroyed. The pointer to G1 is stored in
blocking_allocation_groups_ for S0 at the end of
UpdateActivationDependencies() when it shouldn't be. S0 won't be
notified that G1 is being deleted and the pointer can go stale.

The fix here is to split that problematic loop into two loops. The first
loop updates last pending reference and maybe activates surfaces for the
SurfaceAllocationGroup. The second loop checks for active surfaces and
registers as a blocked embedder. Since the surface isn't registered as a
blocked embedder when other surfaces activate it reduces reentrancy and
prevents the stale pointer.

A logic change is that UpdateLastPendingReferenceAndMaybeActivate() is
now called for all activation dependencies, even when two of them have
the same SurfaceAllocationGroup. Having a surface be blocked on
activation of multiple surface in the same group, aka with the same
embed token, doesn't make much sense but it's allowed by the code.

Lastly change the DCHECK into a CHECK in
OnActivationDependencyResolved(), that ensures dependency being removed
was actually registered. It should always be the case now.

Bug: 540357382
Change-Id: I4bb5858c274c7d76f3da03fcabbf8f947ccd7750
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8297301
Reviewed-by: Saifuddin Hitawala <[email protected]>
Commit-Queue: Kyle Charbonneau <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1686642}
---
diff --git a/components/viz/service/surfaces/surface.cc b/components/viz/service/surfaces/surface.cc
index ae6755d..ae08403 100644
--- a/components/viz/service/surfaces/surface.cc
+++ b/components/viz/service/surfaces/surface.cc
@@ -439,8 +439,8 @@
 void Surface::OnActivationDependencyResolved(
     const SurfaceId& activation_dependency,
     SurfaceAllocationGroup* group) {
-  DCHECK(activation_dependencies_.count(activation_dependency));
-  activation_dependencies_.erase(activation_dependency);
+  size_t erased = activation_dependencies_.erase(activation_dependency);
+  CHECK_EQ(erased, 1u);
   blocking_allocation_groups_.erase(group);
   if (!activation_dependencies_.empty() ||
       !view_transition_dependencies_.empty()) {
@@ -798,6 +798,22 @@
     return;
   }
 
+  // Update the last pending reference in each allocation group. This
+  // may trigger fallback or deadline inheritance activations in those
+  // allocation groups. This surface isn't registered as a blocked embedder
+  // while this happens to avoid re-entrancy.
+  for (const SurfaceId& surface_id :
+       current_frame.metadata.activation_dependencies) {
+    SurfaceAllocationGroup* group =
+        surface_manager_->GetOrCreateAllocationGroupForSurfaceId(surface_id);
+    if (group) {
+      group->UpdateLastPendingReferenceAndMaybeActivate(surface_id);
+    }
+  }
+
+  // Now that all surface activations have happened, inspect which
+  // dependencies remain unresolved and register as a blocked embedder only for
+  // those.
   base::flat_set<raw_ptr<SurfaceAllocationGroup, CtnExperimental>>
       new_blocking_allocation_groups;
   std::vector<SurfaceId> new_activation_dependencies;
@@ -809,8 +825,7 @@
         surface_manager_->GetOrCreateAllocationGroupForSurfaceId(surface_id);
     if (new_blocking_allocation_groups.contains(group))
       continue;
-    if (group)
-      group->UpdateLastPendingReferenceAndMaybeActivate(surface_id);
+
     Surface* dependency = surface_manager_->GetSurfaceForId(surface_id);
     bool is_active = dependency && dependency->HasActiveFrame();
 
diff --git a/components/viz/service/frame_sinks/surface_synchronization_unittest.cc b/components/viz/service/frame_sinks/surface_synchronization_unittest.cc
index faebf8d1..27e0960f 100644
--- a/components/viz/service/frame_sinks/surface_synchronization_unittest.cc
+++ b/components/viz/service/frame_sinks/surface_synchronization_unittest.cc
@@ -3386,10 +3386,10 @@
   // This shouldn't crash.
   parent_support().SubmitCompositorFrame(parent_id.local_surface_id(),
                                          std::move(parent_frame));
-  // When multiple dependencies have the same embed token, only the first one
-  // should be taken into account.
-  EXPECT_EQ(1u, parent_surface()->activation_dependencies().size());
-  EXPECT_EQ(child1_id1, *parent_surface()->activation_dependencies().begin());
+  // When multiple dependencies have the same embed token, the latest should
+  // be taken into account.
+  EXPECT_THAT(parent_surface()->activation_dependencies(),
+              testing::ElementsAre(child1_id2));
 }
 
 class SurfaceSynchronizationTestMayAlwaysAckOnActivation
diff --git a/components/viz/service/surfaces/surface_unittest.cc b/components/viz/service/surfaces/surface_unittest.cc
index b99d871..dfa6ee9 100644
--- a/components/viz/service/surfaces/surface_unittest.cc
+++ b/components/viz/service/surfaces/surface_unittest.cc
@@ -14,6 +14,7 @@
 #include "cc/test/scheduler_test_common.h"
 #include "components/viz/common/features.h"
 #include "components/viz/common/frame_sinks/copy_output_result.h"
+#include "components/viz/common/quads/compositor_frame_transition_directive.h"
 #include "components/viz/common/surfaces/parent_local_surface_id_allocator.h"
 #include "components/viz/common/surfaces/subtree_capture_id.h"
 #include "components/viz/service/frame_sinks/compositor_frame_sink_support.h"
@@ -601,5 +602,86 @@
   EXPECT_TRUE(parent_surface->activation_dependencies().empty());
 }
 
+// Tests that reentrant surface activation doesn't cause a stale
+// SurfaceAllocationGroup* to be stored. See crbug.com/540357382 for details.
+TEST_F(SurfaceTest, ReentrantSurfaceActivationStaleAllocationGroup) {
+  SurfaceManager* surface_manager = frame_sink_manager_.surface_manager();
+
+  constexpr FrameSinkId fs_c1(10, 1);
+  constexpr FrameSinkId fs_c2(20, 1);
+  constexpr FrameSinkId fs_s(30, 1);
+
+  auto c1_support = std::make_unique<CompositorFrameSinkSupport>(
+      nullptr, &frame_sink_manager_, fs_c1, /*is_root=*/false);
+  auto c2_support = std::make_unique<CompositorFrameSinkSupport>(
+      nullptr, &frame_sink_manager_, fs_c2, /*is_root=*/false);
+  auto s_support = std::make_unique<CompositorFrameSinkSupport>(
+      nullptr, &frame_sink_manager_, fs_s, /*is_root=*/false);
+
+  const base::UnguessableToken token_c1 = base::UnguessableToken::Create();
+  const base::UnguessableToken token_c2 = base::UnguessableToken::Create();
+  const base::UnguessableToken token_s = base::UnguessableToken::Create();
+
+  SurfaceId unresolvable_dep(
+      FrameSinkId(99, 1),
+      LocalSurfaceId(1, 1, base::UnguessableToken::Create()));
+
+  auto build_frame = [](std::vector<SurfaceId> deps,
+                        std::vector<SurfaceRange> refs) {
+    return CompositorFrameBuilder()
+        .AddRenderPass(gfx::Rect(10, 10), gfx::Rect(10, 10))
+        .SetActivationDependencies(std::move(deps))
+        .SetReferencedSurfaces(std::move(refs))
+        .SetDeadline(FrameDeadline(base::TimeTicks::Now(), 10000u,
+                                   base::Milliseconds(16), false))
+        .Build();
+  };
+
+  // Step 1: Submit C1 frame with an unresolvable dependency. This creates
+  // allocation group G1 and keeps C1 pending.
+  LocalSurfaceId c1_lsid(2, 1, token_c1);
+  SurfaceId c1_surface_id(fs_c1, c1_lsid);
+  c1_support->SubmitCompositorFrame(
+      c1_lsid,
+      build_frame({unresolvable_dep}, {SurfaceRange(unresolvable_dep)}));
+
+  // Step 2: Submit C2 frame with an unresolvable dependency and referencing
+  // C1's surface. This creates allocation group G2 and keeps C2 pending.
+  LocalSurfaceId c2_lsid(1, 1, token_c2);
+  c2_support->SubmitCompositorFrame(
+      c2_lsid, build_frame({unresolvable_dep}, {SurfaceRange(unresolvable_dep),
+                                                SurfaceRange(c1_surface_id)}));
+
+  // Step 3: Submit surface S that would trigger reentrancy. S depends on an
+  // older surface in G1, so C1(2, 1) satisfies it, and a newer surface in G2,
+  // so C2(1, 1) activates as a fallback. The animate transition directive
+  // ensures that reentrant OnActivationDependencyResolved() returns early
+  // instead of hitting `CHECK(pending_frame_data_)`.
+  SurfaceId c1_older_surface_id(fs_c1, LocalSurfaceId(1, 1, token_c1));
+  SurfaceId c2_newer_surface_id(fs_c2, LocalSurfaceId(2, 1, token_c2));
+
+  CompositorFrame s_frame = build_frame(
+      {c1_older_surface_id, c2_newer_surface_id},
+      {SurfaceRange(c1_older_surface_id), SurfaceRange(c2_newer_surface_id)});
+  s_frame.metadata.transition_directives.push_back(
+      CompositorFrameTransitionDirective::CreateAnimate(
+          blink::ViewTransitionToken(), /*maybe_cross_frame_sink=*/false,
+          /*sequence_id=*/1, /*delay_layer_tree_view_deletion=*/true));
+
+  LocalSurfaceId s_lsid(1, 1, token_s);
+  s_support->SubmitCompositorFrame(s_lsid, std::move(s_frame));
+
+  // Step 4: Remove C2's reference to C1 so G1 has no active embedders.
+  c2_support->SubmitCompositorFrame(c2_lsid, build_frame({}, {}));
+
+  // Destroy C1 and garbage collect. This deletes C1 and destroys G1.
+  c1_support.reset();
+  surface_manager->GarbageCollectSurfaces();
+
+  // Step 5: Submit a new frame for S. UpdateActivationDependencies() iterates
+  // `blocking_allocation_groups_` which shouldn't contain G1.
+  s_support->SubmitCompositorFrame(s_lsid, build_frame({}, {}));
+}
+
 }  // namespace
 }  // namespace viz
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/viz/service/frame_sinks/surface_synchronization_unittest.cc b/components/viz/service/frame_sinks/surface_synchronization_unittest.cc
index faebf8d1..27e0960f 100644
--- a/components/viz/service/frame_sinks/surface_synchronization_unittest.cc
+++ b/components/viz/service/frame_sinks/surface_synchronization_unittest.cc
@@ -3386,10 +3386,10 @@
   // This shouldn't crash.
   parent_support().SubmitCompositorFrame(parent_id.local_surface_id(),
                                          std::move(parent_frame));
-  // When multiple dependencies have the same embed token, only the first one
-  // should be taken into account.
-  EXPECT_EQ(1u, parent_surface()->activation_dependencies().size());
-  EXPECT_EQ(child1_id1, *parent_surface()->activation_dependencies().begin());
+  // When multiple dependencies have the same embed token, the latest should
+  // be taken into account.
+  EXPECT_THAT(parent_surface()->activation_dependencies(),
+              testing::ElementsAre(child1_id2));
 }
 
 class SurfaceSynchronizationTestMayAlwaysAckOnActivation
diff --git a/components/viz/service/surfaces/surface_unittest.cc b/components/viz/service/surfaces/surface_unittest.cc
index b99d871..dfa6ee9 100644
--- a/components/viz/service/surfaces/surface_unittest.cc
+++ b/components/viz/service/surfaces/surface_unittest.cc
@@ -14,6 +14,7 @@
 #include "cc/test/scheduler_test_common.h"
 #include "components/viz/common/features.h"
 #include "components/viz/common/frame_sinks/copy_output_result.h"
+#include "components/viz/common/quads/compositor_frame_transition_directive.h"
 #include "components/viz/common/surfaces/parent_local_surface_id_allocator.h"
 #include "components/viz/common/surfaces/subtree_capture_id.h"
 #include "components/viz/service/frame_sinks/compositor_frame_sink_support.h"
@@ -601,5 +602,86 @@
   EXPECT_TRUE(parent_surface->activation_dependencies().empty());
 }
 
+// Tests that reentrant surface activation doesn't cause a stale
+// SurfaceAllocationGroup* to be stored. See crbug.com/540357382 for details.
+TEST_F(SurfaceTest, ReentrantSurfaceActivationStaleAllocationGroup) {
+  SurfaceManager* surface_manager = frame_sink_manager_.surface_manager();
+
+  constexpr FrameSinkId fs_c1(10, 1);
+  constexpr FrameSinkId fs_c2(20, 1);
+  constexpr FrameSinkId fs_s(30, 1);
+
+  auto c1_support = std::make_unique<CompositorFrameSinkSupport>(
+      nullptr, &frame_sink_manager_, fs_c1, /*is_root=*/false);
+  auto c2_support = std::make_unique<CompositorFrameSinkSupport>(
+      nullptr, &frame_sink_manager_, fs_c2, /*is_root=*/false);
+  auto s_support = std::make_unique<CompositorFrameSinkSupport>(
+      nullptr, &frame_sink_manager_, fs_s, /*is_root=*/false);
+
+  const base::UnguessableToken token_c1 = base::UnguessableToken::Create();
+  const base::UnguessableToken token_c2 = base::UnguessableToken::Create();
+  const base::UnguessableToken token_s = base::UnguessableToken::Create();
+
+  SurfaceId unresolvable_dep(
+      FrameSinkId(99, 1),
+      LocalSurfaceId(1, 1, base::UnguessableToken::Create()));
+
+  auto build_frame = [](std::vector<SurfaceId> deps,
+                        std::vector<SurfaceRange> refs) {
+    return CompositorFrameBuilder()
+        .AddRenderPass(gfx::Rect(10, 10), gfx::Rect(10, 10))
+        .SetActivationDependencies(std::move(deps))
+        .SetReferencedSurfaces(std::move(refs))
+        .SetDeadline(FrameDeadline(base::TimeTicks::Now(), 10000u,
+                                   base::Milliseconds(16), false))
+        .Build();
+  };
+
+  // Step 1: Submit C1 frame with an unresolvable dependency. This creates
+  // allocation group G1 and keeps C1 pending.
+  LocalSurfaceId c1_lsid(2, 1, token_c1);
+  SurfaceId c1_surface_id(fs_c1, c1_lsid);
+  c1_support->SubmitCompositorFrame(
+      c1_lsid,
+      build_frame({unresolvable_dep}, {SurfaceRange(unresolvable_dep)}));
+
+  // Step 2: Submit C2 frame with an unresolvable dependency and referencing
+  // C1's surface. This creates allocation group G2 and keeps C2 pending.
+  LocalSurfaceId c2_lsid(1, 1, token_c2);
+  c2_support->SubmitCompositorFrame(
+      c2_lsid, build_frame({unresolvable_dep}, {SurfaceRange(unresolvable_dep),
+                                                SurfaceRange(c1_surface_id)}));
+
+  // Step 3: Submit surface S that would trigger reentrancy. S depends on an
+  // older surface in G1, so C1(2, 1) satisfies it, and a newer surface in G2,
+  // so C2(1, 1) activates as a fallback. The animate transition directive
+  // ensures that reentrant OnActivationDependencyResolved() returns early
+  // instead of hitting `CHECK(pending_frame_data_)`.
+  SurfaceId c1_older_surface_id(fs_c1, LocalSurfaceId(1, 1, token_c1));
+  SurfaceId c2_newer_surface_id(fs_c2, LocalSurfaceId(2, 1, token_c2));
+
+  CompositorFrame s_frame = build_frame(
+      {c1_older_surface_id, c2_newer_surface_id},
+      {SurfaceRange(c1_older_surface_id), SurfaceRange(c2_newer_surface_id)});
+  s_frame.metadata.transition_directives.push_back(
+      CompositorFrameTransitionDirective::CreateAnimate(
+          blink::ViewTransitionToken(), /*maybe_cross_frame_sink=*/false,
+          /*sequence_id=*/1, /*delay_layer_tree_view_deletion=*/true));
+
+  LocalSurfaceId s_lsid(1, 1, token_s);
+  s_support->SubmitCompositorFrame(s_lsid, std::move(s_frame));
+
+  // Step 4: Remove C2's reference to C1 so G1 has no active embedders.
+  c2_support->SubmitCompositorFrame(c2_lsid, build_frame({}, {}));
+
+  // Destroy C1 and garbage collect. This deletes C1 and destroys G1.
+  c1_support.reset();
+  surface_manager->GarbageCollectSurfaces();
+
+  // Step 5: Submit a new frame for S. UpdateActivationDependencies() iterates
+  // `blocking_allocation_groups_` which shouldn't contain G1.
+  s_support->SubmitCompositorFrame(s_lsid, build_frame({}, {}));
+}
+
 }  // namespace
 }  // namespace viz
Loading diff…

Original Bug Report

reported by [email protected]

[Security][Viz] Re-entrancy desync in Surface::UpdateActivationDependencies leaves a stale SurfaceAllocationGroup pointer — heap-use-after-free in the GPU process


Report description

[Security][Viz] Re-entrancy desync in Surface::UpdateActivationDependencies leaves a stale SurfaceAllocationGroup pointer — heap-use-after-free in the GPU process


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules


The problem

Please describe the technical details of the vulnerability

Summary

Surface::UpdateActivationDependencies() (surface.cc:781-853) publishes this into SurfaceAllocationGroup::blocked_embedders_ before it commits its own bookkeeping members, and the publication call group->UpdateLastPendingReferenceAndMaybeActivate() is re-entrant. During that window the surface’s activation_dependencies_ and blocking_allocation_groups_ are empty, so a re-entrant Surface::OnActivationDependencyResolved() performs two erase() calls that are silent no-ops while the allocation group has already removed the surface from blocked_embedders_.

The result is a permanent desync: the surface holds a pointer to the allocation group, and the allocation group has no record of the surface. The group then satisfies IsReadyToDestroy() and is deleted by SurfaceManager’s garbage collector while the surface still holds and dereferences the pointer, producing a heap-use-after-free in the GPU process on the VizCompositorTh thread.

The desync is normally fatal-but-harmless because the re-entrant path dies on CHECK(pending_frame_data_) (surface.cc:500). Commit 917ae5350f9fc "[viz] Add view transition dependency for Surface activation" added || !view_transition_dependencies_.empty() to the early return at surface.cc:445-448; a kAnimateRenderer transition directive with an unknown token populates that set at CommitFrame:319-336, i.e. before UpdateActivationDependencies runs, so the CHECK is skipped and the desync survives silently.

This is the same bug class as the two July 2026 re-entrancy fixes in this file (4c246406e3da5, bug 514442821 — RecomputeActiveReferencedSurfaces; d798f3bad2546, bug 534858939 — CommitFramesRecursively), in a third function that neither fix touched. UpdateActivationDependencies does not appear in either diff.

Affected version

Product Chrome / Chromium
Version 153.0.7978.0
Build official ASan build, chromium-browser-asan/linux-release/asan-linux-release-1669525.zip, BuildId ec77357d05690f23
Commit 9d4f657b949e4148ad9e43fa2d3c54a3d1e51dcc
Channel All (code present at HEAD; unfixed)
Platform All (verified on Linux x64)
Build config is_asan=true, is_debug=false, dcheck_always_on=false

Vulnerability class

Re-entrancy / state desync → dangling pointer → Heap Use-After-Free (CWE-416)

Component

Internals>Compositing>Viz — components/viz/service/surfaces/surface.cc, components/viz/service/surfaces/surface_allocation_group.cc

Attacker model

Compromised renderer process. The renderer controls the CompositorFrame fields that drive this code: metadata.activation_dependencies, metadata.referenced_surfaces, metadata.deadline, metadata.transition_directives and the LocalSurfaceId (including embed_token), all submitted over viz.mojom.CompositorFrameSink::SubmitCompositorFrame. Per the Chrome VRP rules (docs/security/vrp-faq.md:73-80 — “Instead of providing a patch, use MojoJS to simulate a compromised renderer”), the attached PoC uses MojoJS rather than a patch.

The PoC obtains its frame sinks exclusively through blink.mojom.EmbeddedFrameSinkProvider, the process-scoped interface an ordinary renderer already possesses (content/browser/renderer_host/render_process_host_impl_receiver_bindings.cc:173-176). No privileged viz interface (services/viz/privileged/**, FrameSinkManager, GpuService, DisplayPrivate, VizMain) is used, and the browser-side FrameSinkId gate (client_id must equal the renderer’s ChildProcessId, sink_id must be > INT32_MAX) is respected.

Security boundary

Renderer → GPU process. Viz is the display compositor and runs in the GPU process; the crashing thread is VizCompositorTh and the report is emitted by the GPU process (see evidence). The GPU process is sandboxed on desktop platforms and unsandboxed on Android (docs/security/process-sandboxes-by-platform.md).

The missing invariant is that a Surface and a SurfaceAllocationGroup must agree on whether the surface is a blocked embedder of that group. Nothing enforces it: SurfaceAllocationGroup::UnregisterBlockedEmbedder() guards the relationship with DCHECK(blocked_embedders_.count(surface)), which is compiled out in release.

Root cause

Intended invariant. Surface::blocking_allocation_groups_ and SurfaceAllocationGroup::blocked_embedders_ are two halves of the same relation and must be updated atomically with respect to re-entrancy.

Actual behaviour. Surface::UpdateActivationDependencies() tears the relation down, publishes one half, runs re-entrant code, and only then commits the other half:

// components/viz/service/surfaces/surface.cc:781-853
void Surface::UpdateActivationDependencies(const CompositorFrame& current_frame) {
  // (1) tear down and CLEAR both members
  for (SurfaceAllocationGroup* group : blocking_allocation_groups_)
    group->UnregisterBlockedEmbedder(this, false /* did_activate */);
  blocking_allocation_groups_.clear();
  activation_dependencies_.clear();
  ...
  for (const SurfaceId& surface_id : current_frame.metadata.activation_dependencies) {
    SurfaceAllocationGroup* group =
        surface_manager_->GetOrCreateAllocationGroupForSurfaceId(surface_id);
    ...
    if (group)
      group->UpdateLastPendingReferenceAndMaybeActivate(surface_id);   // (2) RE-ENTRANT
    ...
    if (group) {
      group->RegisterBlockedEmbedder(this, surface_id);                // (3) publishes `this`
      new_blocking_allocation_groups.insert(group);
    }
    new_activation_dependencies.push_back(surface_id);
  }
  // (4) members committed only here
  activation_dependencies_    = std::move(new_activation_dependencies);
  blocking_allocation_groups_ = std::move(new_blocking_allocation_groups);
}

From the second loop iteration onwards, this is registered inside the groups of iterations 0..k-1 (step 3) while its own members are still empty (step 1) and not yet committed (step 4).

Why the existing checks do not help. The re-entrant call at step 2 can reach back into the same surface:

// components/viz/service/surfaces/surface.cc:439-455
void Surface::OnActivationDependencyResolved(const SurfaceId& activation_dependency,
                                             SurfaceAllocationGroup* group) {
  DCHECK(activation_dependencies_.count(activation_dependency));   // compiled out
  activation_dependencies_.erase(activation_dependency);           // NO-OP: cleared at :788
  blocking_allocation_groups_.erase(group);                        // NO-OP: cleared at :787
  if (!activation_dependencies_.empty() ||
      !view_transition_dependencies_.empty()) {                    // <- added by 917ae5350f9fc
    return;                                                        //    skips the CHECK below
  }
  ...
  ActivatePendingFrame();          // would CHECK(pending_frame_data_) at :500 and abort
}

Meanwhile the group has already erased the surface before dispatching (surface_allocation_group.cc:173-176, erase-then-notify). Step 4 then commits new_blocking_allocation_groups, which still contains that group.

Net state: surface → group, group ↛ surface.

Because the group no longer lists the surface as a blocked embedder, it satisfies SurfaceAllocationGroup::IsReadyToDestroy() (surface_allocation_group.cc:31-33) and is destroyed by SurfaceManager::MaybeGarbageCollectAllocationGroups(). And because the surface’s destructor only unregisters from groups listed in its own set, the relation is never repaired:

// components/viz/service/surfaces/surface.cc:150-156  (~Surface)
for (SurfaceAllocationGroup* group : blocking_allocation_groups_)
  group->UnregisterBlockedEmbedder(this, false /* did_activate */);

Source-to-sink trace

  1. Compromised renderer submits a CompositorFrame for surface S whose metadata.activation_dependencies name surfaces in two distinct allocation groups, whose metadata.deadline is non-zero (so the frame stays pending), and whose metadata.transition_directives contain a kAnimateRenderer directive with an unknown token, populating view_transition_dependencies_ at CommitFrame:319-336.
  2. Surface::CommitFrame → Surface::UpdateActivationDependencies (surface.cc:781).
  3. :783-788 unregisters S from all groups and clears blocking_allocation_groups_ / activation_dependencies_.
  4. Loop iteration 1 registers S into group G (:841 RegisterBlockedEmbedder).
  5. Loop iteration 2 calls the re-entrant :813 group->UpdateLastPendingReferenceAndMaybeActivate(surface_id) → SurfaceAllocationGroup::SetIsFallbackAndMaybeActivate → child activation → Surface::ActivateFrame → RecomputeActiveReferencedSurfaces → SurfaceAllocationGroup::UpdateLastActiveReferenceAndMaybeActivate → a second child activation → SurfaceAllocationGroup::OnFirstSurfaceActivation (surface_allocation_group.cc:173-176) → back into S: Surface::OnActivationDependencyResolved.
  6. In S, both erase() calls at :443-444 are no-ops (the sets were cleared at step 3); the early return at :445-448 is taken because view_transition_dependencies_ is non-empty, so the otherwise-fatal CHECK(pending_frame_data_) at :500 is skipped. G has already erased S from blocked_embedders_.
  7. :852-853 commits new_blocking_allocation_groups, which still contains G. Desync established.
  8. G becomes ready to destroy and is freed by SurfaceManager::ExpireOldTemporaryReferences → GarbageCollectSurfaces → MaybeGarbageCollectAllocationGroups (a base::RepeatingTimer).
  9. A later BeginFrame activates S: CompositorFrameSinkSupport::OnBeginFrame → CheckPendingSurfaces → Surface::ActivateIfDeadlinePassed → ActivatePendingFrame → Surface::ActivateFrame, whose loop at surface.cc:698-699 dereferences the freed G:
    for (SurfaceAllocationGroup* group : blocking_allocation_groups_)
      group->UnregisterBlockedEmbedder(this, true /* did_activate */);
    blocking_allocation_groups_.clear();
    

Sink-to-source trace

  • Sink: read of the freed SurfaceAllocationGroup inside SurfaceAllocationGroup::UnregisterBlockedEmbedder() (the blocked_embedders_ flat_map’s vector members, 48 bytes into the 168-byte object).
  • Holder: base::flat_set<raw_ptr<SurfaceAllocationGroup, CtnExperimental>> blocking_allocation_groups_ (surface.h:442).
  • Freer: SurfaceManager::MaybeGarbageCollectAllocationGroups(), reached from the ExpireOldTemporaryReferences repeating timer.
  • No guard: the relation is asserted only by DCHECK(blocked_embedders_.count(surface)), compiled out in release; there is no re-entrancy flag, no copy-before-publish, and no post-condition check.
  • Source: renderer-controlled CompositorFrame metadata over viz.mojom.CompositorFrameSink::SubmitCompositorFrame.

Reproduction steps

No Chromium build is required — the PoC runs against the official ASan build.

  1. Fetch and unpack https://commondatastorage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1669525.zip.
  2. From the PoC directory, serve the harness and run the driver:
    python3 vizdrv.py poc_config.html --config e2_stale_group_uaf_45s.json --seconds 90
    
    The driver starts a loopback HTTP server, launches the ASan Chrome with
    --headless=new --no-sandbox --enable-blink-features=MojoJS,MojoJSTest
    --no-zygote --gpu-launcher='env ASAN_OPTIONS=…:handle_abort=1:allow_user_segv_handler=0…'
    
    and captures the GPU process’s ASan output.
  3. The GPU process aborts with AddressSanitizer: heap-use-after-free in viz::SurfaceAllocationGroup::UnregisterBlockedEmbedder.

The page creates several CompositorFrameSinks through blink.mojom.EmbeddedFrameSinkProvider and submits CompositorFrames whose activation dependencies, referenced surfaces and transition directives establish the desync described above; the free is then performed by viz’s own garbage-collection timer, and the use by the next BeginFrame.

Flags. --no-sandbox is a requirement of the ASan build, not of the bug. --enable-blink-features=MojoJS,MojoJSTest is the sanctioned compromised-renderer simulation. --disable-gpu is not required — verified by re-running without it. No feature flags, no --enable-features, no origin trial.

PoC attachments

file what it is
poc_config.html + viz_harness.js + vizdrv.py + serve.py MojoJS harness and driver (pure Python server, per the reporting rules)
e2_stale_group_uaf_45s.json the PoC configuration — 5/5
control_a_no_vt_directive.json identical except the kAnimateRenderer directive is removed → CHECK failed: pending_frame_data_, no ASan report
control_b_no_reentry_edge.json identical except the re-entrancy edge is removed → clean run
asan_45s_run1.txt … asan_45s_run5.txt five symbolized GPU-process ASan reports
control_a_report.txt the CHECK output from control A
viz-uaf-surfaceallocationgroup-fix.patch suggested fix (see below); applies cleanly to 9d4f657b

Actual result

AddressSanitizer: heap-use-after-free, READ of size 8, in the GPU process on VizCompositorTh, when Surface::ActivateFrame iterates blocking_allocation_groups_ and calls into an allocation group that the surface’s own registration was silently dropped from.

Expected result

Surface::blocking_allocation_groups_ and SurfaceAllocationGroup::blocked_embedders_ remain consistent across re-entrancy, or the surface never retains a group it is not registered with.

Crash / ASan evidence

==739740==ERROR: AddressSanitizer: heap-use-after-free on address 0x7c1d674aad20
READ of size 8 at 0x7c1d674aad20 thread T27 (VizCompositorTh)
    #0 viz::SurfaceAllocationGroup::UnregisterBlockedEmbedder(viz::Surface*, bool)
         third_party/libc++/src/include/__vector/vector.h:351:57
    #1 viz::Surface::ActivateFrame(viz::Surface::FrameData)   components/viz/service/surfaces/surface.cc:699:12
    #2 viz::Surface::ActivatePendingFrame()                   surface.cc:514:3
    #3 viz::Surface::ActivatePendingFrameForDeadline()        surface.cc:472:3
    #4 viz::Surface::ActivateIfDeadlinePassed()               surface.cc:249:3
    #5 viz::CompositorFrameSinkSupport::CheckPendingSurfaces()
         components/viz/service/frame_sinks/compositor_frame_sink_support.cc:1572:14
    #6 viz::CompositorFrameSinkSupport::OnBeginFrame(viz::BeginFrameArgs const&)
         compositor_frame_sink_support.cc:1152:3
    #7 viz::DelayBasedBeginFrameSource::IssueBeginFrameToObserver(...)
         components/viz/common/frame_sinks/begin_frame_source.cc:50:13

0x7c1d674aad20 is located 48 bytes inside of 168-byte region [0x7c1d674aacf0,0x7c1d674aad98)

freed by thread T27 (VizCompositorTh) here:
    #1 viz::SurfaceManager::MaybeGarbageCollectAllocationGroups()
    #2 viz::SurfaceManager::GarbageCollectSurfaces()      components/viz/service/surfaces/surface_manager.cc:248:3
    #3 viz::SurfaceManager::ExpireOldTemporaryReferences() surface_manager.cc:493:3
    #6 base::RepeatingTimer::RunUserTask()                 base/timer/timer.cc:215:8

previously allocated by thread T27 (VizCompositorTh) here:
    #1 viz::SurfaceManager::GetOrCreateAllocationGroupForSurfaceId(viz::SurfaceId const&)
    #2 viz::SurfaceManager::CreateSurface(...)             surface_manager.cc:136:7
    #3 viz::CompositorFrameSinkSupport::MaybeSubmitCompositorFrame(...)
         compositor_frame_sink_support.cc:886:52
    #4 viz::CompositorFrameSinkImpl::SubmitCompositorFrame(...)
         components/viz/service/frame_sinks/compositor_frame_sink_impl.cc:166:33
    #5 viz::mojom::CompositorFrameSinkStubDispatch::Accept(viz::mojom::CompositorFrameSink*, mojo::Message*)
    #6 mojo::InterfaceEndpointClient::HandleValidatedMessage(mojo::Message*)

SUMMARY: AddressSanitizer: heap-use-after-free __vector/vector.h:351:57
         in viz::SurfaceAllocationGroup::UnregisterBlockedEmbedder(viz::Surface*, bool)

The 168-byte region is SurfaceAllocationGroup. The allocation stack ends in mojom::CompositorFrameSinkStubDispatch::Accept, i.e. the object is created directly by a renderer IPC; the free is performed by viz’s own GC timer; the use happens on the next BeginFrame.

Reproduction rate: 5/5 with e2_stale_group_uaf_45s.json. Independently re-run 4/4 by a second operator, including one run with --disable-gpu removed.

Controls: removing only the kAnimateRenderer transition directive changes the outcome to Check failed: pending_frame_data_ at surface.cc:500 with no ASan report — isolating the single ingredient that converts a fatal CHECK into silent memory corruption. Removing only the re-entrancy edge yields a clean run. A separate channel-proof run (chrome://crash/gpu/use-after-free) confirms ASan reporting works in that same GPU process, so a clean control is a real negative.

Impact analysis

Impact analysis

Security impact

Renderer-controlled heap use-after-free in the GPU process, which serves every renderer, so this is a cross-renderer surface. The freed object is a 168-byte SurfaceAllocationGroup; the use is a read of its blocked_embedders_ container members followed by a flat_map erase through those members.

Per docs/security/severity-guidelines.md, memory corruption in the GPU process where it is sandboxed is High (S1) (:83). The GPU process is unsandboxed on Android (docs/security/process-sandboxes-by-platform.md); the frame fields required here are produced by cc rather than by ordinary JavaScript, so this is reported as compromised-renderer reachable (S1-shaped) rather than directly web-content reachable.

MiraclePtr analysis (stated explicitly)

ASan reports:

MiraclePtr Status: MANUAL ANALYSIS REQUIRED
A pointer to the same region was extracted from a raw_ptr<T> object prior to this crash.

Performing that analysis: the dangling pointer is held in base::flat_set<raw_ptr<SurfaceAllocationGroup, CtnExperimental>> blocking_allocation_groups_ (surface.h:442), and blocking_allocation_groups_.clear() occurs after the loop that dereferences the extracted plain pointer (surface.cc:698-700), so the raw_ptr is alive across the whole extraction→use window. Under BackupRefPtr the slot would therefore stay quarantined, and severity-guidelines.md:223-236 would apply.

This is disclosed proactively rather than argued around. Two points remain relevant:

  1. The same underlying defect — a torn Surface ↔ SurfaceAllocationGroup relation — also exists in the inverse direction, where the stale pointer is a plain Surface*: SurfaceAllocationGroup::blocked_embedders_ is declared base::flat_map<Surface*, SurfaceId> (surface_allocation_group.h:170) while both sibling containers in the same class (surfaces_, active_embedders_) use raw_ptr. A surface that is missing from its own blocking_allocation_groups_ is never removed from a group’s blocked_embedders_ by ~Surface (surface.cc:150-156), and the group later dereferences that plain pointer at surface_allocation_group.cc:176 and :183. MiraclePtr does not cover that pointer. (Investigation of this direction is in progress and will be supplied as a follow-up if it reproduces.)
  2. Independently of exploitability, the desync is a correctness defect that today only fails safe by accident, via a CHECK that a shipped feature (917ae5350f9fc) learned to skip.

Reachability

from reachable
ordinary web content Not demonstrated. The required CompositorFrame fields are emitted by cc, not by JS.
compromised renderer Yes — viz.mojom.CompositorFrameSink::SubmitCompositorFrame, obtained via the process-scoped blink.mojom.EmbeddedFrameSinkProvider.

Regression / bisection

Not a regression of the July 2026 hardening work — Surface::UpdateActivationDependencies was never covered by it. 4c246406e3da5 (bug 514442821) fixed Surface::RecomputeActiveReferencedSurfaces; d798f3bad2546 (bug 534858939) fixed Surface::CommitFramesRecursively and SurfaceManager. The string UpdateActivationDependencies appears zero times in both diffs.

The silent-failure mode was introduced by 917ae5350f9fc "[viz] Add view transition dependency for Surface activation" (2025-09-22), which added the view_transition_dependencies_ clause to the early return at surface.cc:445-448; before it, the same desync terminated at CHECK(pending_frame_data_).

Suggested fix

Attached as viz-uaf-surfaceallocationgroup-fix.patch (applies cleanly to 9d4f657b, verified with git apply --check).

The minimal change is to stop building the relation in locals that are only committed after the loop, and instead update blocking_allocation_groups_ and activation_dependencies_ in place, so that a re-entrant OnActivationDependencyResolved() observes — and can correctly erase from — the state that the groups have already been told about:

--- a/components/viz/service/surfaces/surface.cc
+++ b/components/viz/service/surfaces/surface.cc
@@ void Surface::UpdateActivationDependencies(const CompositorFrame& current_frame) {
-  base::flat_set<raw_ptr<SurfaceAllocationGroup, CtnExperimental>>
-      new_blocking_allocation_groups;
-  std::vector<SurfaceId> new_activation_dependencies;
+  // |blocking_allocation_groups_| and |activation_dependencies_| are updated in
+  // place below rather than being built in locals and committed after the loop.
+  // UpdateLastPendingReferenceAndMaybeActivate() is re-entrant and can call
+  // back into Surface::OnActivationDependencyResolved() for a group registered
+  // by an earlier iteration. With the members still empty, that callback's
+  // erase() calls are silent no-ops while the group has already removed this
+  // surface from its |blocked_embedders_|, leaving this surface holding a
+  // group that has no record of it. The group is then garbage collected while
+  // this surface still dereferences it (see Surface::ActivateFrame()).
   bool bypass_outdated_surface_activation =
@@
-    if (new_blocking_allocation_groups.contains(group))
+    if (blocking_allocation_groups_.contains(group))
       continue;
@@
     if (group) {
       group->RegisterBlockedEmbedder(this, surface_id);
-      new_blocking_allocation_groups.insert(group);
+      blocking_allocation_groups_.insert(group);
     }
@@
-    new_activation_dependencies.push_back(surface_id);
+    activation_dependencies_.insert(surface_id);
   }
-  activation_dependencies_ = std::move(new_activation_dependencies);
-  blocking_allocation_groups_ = std::move(new_blocking_allocation_groups);
 }

Note for owners: this trades the single sorted-vector assignment for per-element base::flat_set::insert(). activation_dependencies is small and bounded in practice, but if that cost matters the same invariant can be restored by splitting the loop into a non-re-entrant “compute and register” pass followed by a second pass that performs the UpdateLastPendingReferenceAndMaybeActivate() calls after the members are committed.

Two hardening changes are worth making alongside it, independently of this bug:

  • promote DCHECK(blocked_embedders_.count(surface)) in SurfaceAllocationGroup::UnregisterBlockedEmbedder to a CHECK, so a torn relation fails loudly in release instead of silently leaking a stale pointer;
  • change base::flat_map<Surface*, SurfaceId> blocked_embedders_ (surface_allocation_group.h:170) to raw_ptr<Surface> keys, matching surfaces_ and active_embedders_ in the same class.

The cause

What version of Chrome have you found the security issue in?

153.0.7978.0

Yes, it is related to a crash.

Choose the type of vulnerability

Memory Corruption (in a non-sandboxed process)

How would you like to be publicly acknowledged for your report?

Ngoc Hieu

View on issue tracker