CVE-2026-85048
Overview
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
Surfaceblocked 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
Surfaceis still waiting on, and the set of rawSurfaceAllocationGroup*it has registered against as a blocked embedder. - `UpdateLastPendingReferenceAndMaybeActivate`
- a
SurfaceAllocationGroupmethod that updates its last pending reference and may synchronously activate dependent surfaces, which can re-enterSurfaceactivation 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.
SurfaceAllocationGroup*; the fix performs all activations first, then registers blocked-embedder membership only for still-unresolved dependencies.Attack Path
- Create a surface with two dependencies in reentrant groups
Submit frames so that surface
S0has activation dependenciesS1(groupG1) andS2(groupG2), withS1initially lacking an active frame. - Register then trigger reentrancy
In the old single loop,
S0registers as a blocked embedder ofG1and storesG1innew_blocking_allocation_groups; a later iteration activatesS2, which reentrantly activatesS1. - Delete the group while still referenced
With
S1activated and then destroyed (and its embedder reference removed),G1is garbage-collected, butS0still holdsG1inblocking_allocation_groups_and was never notified of the deletion, leaving a stale raw pointer. - Use the stale pointer
A subsequent
UpdateActivationDependencies()onS0iteratesblocking_allocation_groups_(which wrongly still contains the freedG1), a use-after-free, matching the advisory’s use-after-free in Compositing; the regression testReentrantSurfaceActivationStaleAllocationGroupreproduces exactly this sequence.
Impact Assessment
(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
| Function | Change | Notes |
|---|---|---|
ifcomponents/viz/service/surfaces/surface.cc |
modified | |
SurfaceSynchronizationTestMayAlwaysAckOnActivationcomponents/viz/service/frame_sinks/surface_synchronization_unittest.cc |
modified | |
TEST_Fcomponents/viz/service/surfaces/surface_unittest.cc |
modified |
Files Changed
components/viz/service/frame_sinks/surface_synchronization_unittest.cccomponents/viz/service/surfaces/surface.cccomponents/viz/service/surfaces/surface_unittest.cc
Audit Directions
- Register-before-reentrancy orderingAudit 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 callbacksReview
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 invariantsWhere a
DCHECKasserts a container-membership/lifetime precondition, consider whether it masks a real reentrancy bug that only manifests in release builds, and whether it should be aCHECK.
Patch
From 053609913f8db7469fff46fe0170001d4123b094 Mon Sep 17 00:00:00 2001
From: kylechar <kylechar@chromium.org>
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 <hitawala@chromium.org>
Commit-Queue: Kyle Charbonneau <kylechar@chromium.org>
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
Regression Test / PoC
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
Original Bug Report
[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
- Compromised renderer submits a
CompositorFramefor surface S whosemetadata.activation_dependenciesname surfaces in two distinct allocation groups, whosemetadata.deadlineis non-zero (so the frame stays pending), and whosemetadata.transition_directivescontain akAnimateRendererdirective with an unknown token, populatingview_transition_dependencies_atCommitFrame:319-336. Surface::CommitFrame→Surface::UpdateActivationDependencies(surface.cc:781).:783-788unregisters S from all groups and clearsblocking_allocation_groups_/activation_dependencies_.- Loop iteration 1 registers S into group G (
:841 RegisterBlockedEmbedder). - 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. - In S, both
erase()calls at:443-444are no-ops (the sets were cleared at step 3); the early return at:445-448is taken becauseview_transition_dependencies_is non-empty, so the otherwise-fatalCHECK(pending_frame_data_)at:500is skipped. G has already erased S fromblocked_embedders_. :852-853commitsnew_blocking_allocation_groups, which still contains G. Desync established.- G becomes ready to destroy and is freed by
SurfaceManager::ExpireOldTemporaryReferences→GarbageCollectSurfaces→MaybeGarbageCollectAllocationGroups(abase::RepeatingTimer). - A later
BeginFrameactivates S:CompositorFrameSinkSupport::OnBeginFrame→CheckPendingSurfaces→Surface::ActivateIfDeadlinePassed→ActivatePendingFrame→Surface::ActivateFrame, whose loop atsurface.cc:698-699dereferences 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
SurfaceAllocationGroupinsideSurfaceAllocationGroup::UnregisterBlockedEmbedder()(theblocked_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 theExpireOldTemporaryReferencesrepeating 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
CompositorFramemetadata overviz.mojom.CompositorFrameSink::SubmitCompositorFrame.
Reproduction steps
No Chromium build is required — the PoC runs against the official ASan build.
- Fetch and unpack
https://commondatastorage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1669525.zip. - From the PoC directory, serve the harness and run the driver:
The driver starts a loopback HTTP server, launches the ASan Chrome with
python3 vizdrv.py poc_config.html --config e2_stale_group_uaf_45s.json --seconds 90
and captures the GPU process’s ASan output.--headless=new --no-sandbox --enable-blink-features=MojoJS,MojoJSTest --no-zygote --gpu-launcher='env ASAN_OPTIONS=…:handle_abort=1:allow_user_segv_handler=0…' - The GPU process aborts with
AddressSanitizer: heap-use-after-freeinviz::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:
- The same underlying defect — a torn
Surface↔SurfaceAllocationGrouprelation — also exists in the inverse direction, where the stale pointer is a plainSurface*:SurfaceAllocationGroup::blocked_embedders_is declaredbase::flat_map<Surface*, SurfaceId>(surface_allocation_group.h:170) while both sibling containers in the same class (surfaces_,active_embedders_) useraw_ptr. A surface that is missing from its ownblocking_allocation_groups_is never removed from a group’sblocked_embedders_by~Surface(surface.cc:150-156), and the group later dereferences that plain pointer atsurface_allocation_group.cc:176and: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.) - 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))inSurfaceAllocationGroup::UnregisterBlockedEmbedderto aCHECK, 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) toraw_ptr<Surface>keys, matchingsurfaces_andactive_embedders_in the same class.
The cause
What version of Chrome have you found the security issue in?
153.0.7978.0
Is the security issue related to a crash?
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
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://commondatastorage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1669525.zip
- https://issuetracker.google.com/issues/514442821
- https://issuetracker.google.com/issues/534858939