CVE-2026-17650
Overview
Background
- Surface
- A Viz-side object holding a client’s
CompositorFramedata; aSurfacecan hold both anactive_frame_data_(the frame currently used for drawing) and a pending frame awaiting its dependencies. - Referenced surfaces
- The set of
SurfaceRanges in a frame’smetadata.referenced_surfacesthat identify other surfaces this frame embeds, used bySurfaceManagerto track cross-surface references. - Activation dependency
- A
SurfaceIda pending frame is blocked on; when a referenced child surface activates it can synchronously resolve a parent’s dependency and promote the parent’s pending frame to active. - Sync re-entrancy
- Re-entering surface bookkeeping code from within an ongoing surface operation, here because activating one surface synchronously drives activation of another during the same call stack.
Root Cause Analysis
The vulnerable code in Surface::RecomputeActiveReferencedSurfaces (named in the commit message; the @@ -614 hunk header does not carry the function name) iterated directly over active_frame_data_->frame.metadata.referenced_surfaces by reference. Inside the loop it called UpdateLastActiveReferenceAndMaybeActivate() (per the added comment), which can synchronously activate a referenced child surface; that activation can in turn resolve an activation dependency of this surface’s own pending frame, causing the pending frame to be promoted and active_frame_data_ to be overwritten mid-iteration. Because the loop held a reference into the vector owned by the now-replaced active_frame_data_, the container it was walking was freed out from under it, leaving the iterator and the SurfaceRange reference dangling — a classic modify-during-iteration use-after-free.
The fix takes a local copy, const std::vector<SurfaceRange> referenced_surfaces = active_frame_data_->frame.metadata.referenced_surfaces;, and iterates over that copy, so the loop’s backing storage survives regardless of what happens to active_frame_data_ during re-entrant activation.
The added regression test PendingFrameActivatesWhileRecomputingReferences constructs exactly this chain and asserts the parent’s active frame is safely replaced without crashing.
active_frame_data_ while calling into code that can synchronously replace active_frame_data_ (and thus free that container); the fix eliminates the dangling access by snapshotting referenced_surfaces into a stack-local copy before the loop.Attack Path
- Set up an active parent embedding multiple child groups
A client submits a parent
CompositorFramereferencing several child allocation groups (e.g.child_id1a,child_id2) that do not yet exist, so it activates immediately and the parent becomes an active embedder of those groups. - Queue a pending parent frame blocked on a child
The client submits a second parent frame whose activation dependency is a specific child (
child_id1a); it stays pending while the first frame remains the parent’s active frame, so the parent now holds both an active and a pending frame. - Arm a pending child that can be force-activated
A frame is submitted to that child (
child_id1a) depending on a surface that will never exist, leaving the child pending but eligible to be force-activated when the parent later processes its reference. - Trigger re-entrant activation during recomputation
Activating an unrelated child (
child_id2) notifies the parent and runsSurface::RecomputeActiveReferencedSurfaces; while iterating references it force-activateschild_id1a, which resolves the parent’s pending dependency and replacesactive_frame_data_while the loop still references the old frame’sreferenced_surfacesvector, producing the use-after-free. - Escalate via the freed allocation
Controlling the timing and contents of the reallocated memory to turn the dangling read/iteration into a controllable corruption primitive is a further step the diff does not demonstrate
(inferred).
Impact Assessment
CompositorFrames (a compromised or malicious renderer driving Viz surface synchronization) can trigger a use-after-free in the Viz compositing service, which runs in the GPU process. The preconditions are the specific frame-submission sequence that creates a parent with both active and pending frames plus a referenced child that force-activates during reference recomputation, as encoded in the regression test. The advisory classifies this as a use-after-free in Compositing; any escalation to memory disclosure, controllable corruption, or code execution in the GPU process is (inferred) and not established by the patch, and the advisory provided states no exploited-in-the-wild status.Changed Functions
| Function | Change | Notes |
|---|---|---|
forcomponents/viz/service/surfaces/surface.cc |
modified |
Files Changed
components/viz/service/frame_sinks/surface_synchronization_unittest.cccomponents/viz/service/surfaces/surface.cc
Audit Directions
- Iterate-then-callback over owned containersAudit loops that walk a vector or map reachable through a member pointer (like
active_frame_data_) while calling into code that can synchronously mutate or replace that member; prefer snapshotting or index-based revalidation. - Synchronous surface activation re-entrancyReview other Viz
Surface/SurfaceManagerpaths where activation of one surface can cascade into activation or destruction of another within the same call stack (e.g. dependency resolution,UpdateLastActiveReferenceAndMaybeActivatecallers) for stale references to pre-callback state. - Active-vs-pending frame swapsCheck code assuming
active_frame_data_is stable across a callback boundary, since pending-to-active promotion can invalidate any reference, iterator, or cached pointer derived from the previous active frame.
Patch
From 4c246406e3da54bcb173894181a664b84450dcd9 Mon Sep 17 00:00:00 2001 From: Saifuddin Hitawala <[email protected]> Date: Tue, 14 Jul 2026 08:29:03 -0700 Subject: [PATCH] [viz] Fix UAF through surface re-entrancy for referenced surfaces Guard against UAF through vector modification over sync re-entrancy in Surface::RecomputeActiveReferencedSurfaces. This can happen when a dependent surface gets activated which causes referenced surfaces to be recomputed and a pending surface on same frame to be activated which overwrites the currently active frame's data and leads to dangling refs. This change makes a local copy and adds unittest to verify this. Bug: 514442821 Change-Id: I5093537dbb0f627265c715ca326629db4f417d7f Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8085883 Reviewed-by: Vasiliy Telezhnikov <[email protected]> Commit-Queue: Saifuddin Hitawala <[email protected]> Cr-Commit-Position: refs/heads/main@{#1661877} --- diff --git a/components/viz/service/frame_sinks/surface_synchronization_unittest.cc b/components/viz/service/frame_sinks/surface_synchronization_unittest.cc index 8ae9bdf0..d98140b 100644 --- a/components/viz/service/frame_sinks/surface_synchronization_unittest.cc +++ b/components/viz/service/frame_sinks/surface_synchronization_unittest.cc @@ -3575,6 +3575,70 @@ EXPECT_TRUE(child_surface1()->HasUnackedActiveFrame()); } +// Tests that the parent's active frame can be replaced by a pending frame while +// the parent is recomputing its referenced surfaces. This happens when +// processing a referenced surface causes a child to activate, which in turn +// resolves the activation dependency of the parent's pending frame. +TEST_F(SurfaceSynchronizationTest, + PendingFrameActivatesWhileRecomputingReferences) { + const SurfaceId parent_id = MakeSurfaceId(kParentFrameSink, 1); + // Two SurfaceIds in the same allocation group with incomparable sequence + // numbers so that neither is considered newer than the other. + const SurfaceId child_id1a = MakeSurfaceId(kChildFrameSink1, 1, 2); + const SurfaceId child_id1b = MakeSurfaceId(kChildFrameSink1, 2, 1); + const SurfaceId child_id2 = MakeSurfaceId(kChildFrameSink2, 1); + const SurfaceId arbitrary_id = MakeSurfaceId(kArbitraryFrameSink, 1); + + // Submit the first parent frame referencing both child allocation groups. + // Neither child exists yet so the frame activates immediately and the parent + // becomes an active embedder of both groups. + parent_support().SubmitCompositorFrame( + parent_id.local_surface_id(), + MakeCompositorFrame(empty_surface_ids(), + {SurfaceRange(std::nullopt, child_id1a), + SurfaceRange(std::nullopt, child_id2), + SurfaceRange(std::nullopt, child_id1b), + SurfaceRange(std::nullopt, arbitrary_id)}, + std::vector<TransferableResource>())); + EXPECT_TRUE(parent_surface()->HasActiveFrame()); + EXPECT_FALSE(parent_surface()->HasPendingFrame()); + // Submit a second parent frame that depends on |child_id1a|. It stays + // pending and the parent becomes a blocked embedder of |child_id1a|'s + // allocation group. + parent_support().SubmitCompositorFrame( + parent_id.local_surface_id(), + MakeCompositorFrame({child_id1a}, empty_surface_ranges(), + std::vector<TransferableResource>(), + MakeDefaultDeadline())); + EXPECT_TRUE(parent_surface()->HasActiveFrame()); + EXPECT_TRUE(parent_surface()->HasPendingFrame()); + EXPECT_THAT(parent_surface()->activation_dependencies(), + UnorderedElementsAre(child_id1a)); + // Submit a frame to |child_id1a| that depends on a surface that will never + // exist. It stays pending. + child_support1().SubmitCompositorFrame( + child_id1a.local_surface_id(), + MakeCompositorFrame({arbitrary_id}, empty_surface_ranges(), + std::vector<TransferableResource>(), + MakeDefaultDeadline())); + EXPECT_FALSE(child_surface1()->HasActiveFrame()); + EXPECT_TRUE(child_surface1()->HasPendingFrame()); + // Submit a frame to |child_id2| with no dependencies. Its activation + // notifies the parent, which recomputes its referenced surfaces. While + // processing the |child_id1a| reference, that child is force-activated which + // in turn resolves the dependency of the parent's pending frame, replacing + // the parent's active frame mid-iteration. This must not crash. + child_support2().SubmitCompositorFrame( + child_id2.local_surface_id(), + MakeCompositorFrame(empty_surface_ids(), empty_surface_ranges(), + std::vector<TransferableResource>())); + + EXPECT_TRUE(child_surface1()->HasActiveFrame()); + EXPECT_TRUE(parent_surface()->HasActiveFrame()); + EXPECT_FALSE(parent_surface()->HasPendingFrame()); + EXPECT_THAT(parent_surface()->activation_dependencies(), IsEmpty()); +} + // Tests that when a CompositorFrame for an Embedded Surface arrives after its // Embedder has submitted new ActivationDependencies, that it is immediately // ACKed, even if normally it would not be due to damage. This way we don't have diff --git a/components/viz/service/surfaces/surface.cc b/components/viz/service/surfaces/surface.cc index 980f51a..0abab86 100644 --- a/components/viz/service/surfaces/surface.cc +++ b/components/viz/service/surfaces/surface.cc @@ -614,8 +614,13 @@ // notify SurfaceManager of the new references. active_referenced_surfaces_.clear(); std::vector<SurfaceAllocationGroup*> new_referenced_allocation_groups; - for (const SurfaceRange& surface_range : - active_frame_data_->frame.metadata.referenced_surfaces) { + // Iterate over a copy because UpdateLastActiveReferenceAndMaybeActivate() + // can synchronously activate a referenced surface, which can resolve a + // dependency of this surface's pending frame and replace + // |active_frame_data_| while we're iterating over it. + const std::vector<SurfaceRange> referenced_surfaces = + active_frame_data_->frame.metadata.referenced_surfaces; + for (const SurfaceRange& surface_range : referenced_surfaces) { // Figure out what surface in the |surface_range| needs to be referenced. Surface* surface = surface_manager_->GetLatestInFlightSurface(surface_range);
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 8ae9bdf0..d98140b 100644
--- a/components/viz/service/frame_sinks/surface_synchronization_unittest.cc
+++ b/components/viz/service/frame_sinks/surface_synchronization_unittest.cc
@@ -3575,6 +3575,70 @@
EXPECT_TRUE(child_surface1()->HasUnackedActiveFrame());
}
+// Tests that the parent's active frame can be replaced by a pending frame while
+// the parent is recomputing its referenced surfaces. This happens when
+// processing a referenced surface causes a child to activate, which in turn
+// resolves the activation dependency of the parent's pending frame.
+TEST_F(SurfaceSynchronizationTest,
+ PendingFrameActivatesWhileRecomputingReferences) {
+ const SurfaceId parent_id = MakeSurfaceId(kParentFrameSink, 1);
+ // Two SurfaceIds in the same allocation group with incomparable sequence
+ // numbers so that neither is considered newer than the other.
+ const SurfaceId child_id1a = MakeSurfaceId(kChildFrameSink1, 1, 2);
+ const SurfaceId child_id1b = MakeSurfaceId(kChildFrameSink1, 2, 1);
+ const SurfaceId child_id2 = MakeSurfaceId(kChildFrameSink2, 1);
+ const SurfaceId arbitrary_id = MakeSurfaceId(kArbitraryFrameSink, 1);
+
+ // Submit the first parent frame referencing both child allocation groups.
+ // Neither child exists yet so the frame activates immediately and the parent
+ // becomes an active embedder of both groups.
+ parent_support().SubmitCompositorFrame(
+ parent_id.local_surface_id(),
+ MakeCompositorFrame(empty_surface_ids(),
+ {SurfaceRange(std::nullopt, child_id1a),
+ SurfaceRange(std::nullopt, child_id2),
+ SurfaceRange(std::nullopt, child_id1b),
+ SurfaceRange(std::nullopt, arbitrary_id)},
+ std::vector<TransferableResource>()));
+ EXPECT_TRUE(parent_surface()->HasActiveFrame());
+ EXPECT_FALSE(parent_surface()->HasPendingFrame());
+ // Submit a second parent frame that depends on |child_id1a|. It stays
+ // pending and the parent becomes a blocked embedder of |child_id1a|'s
+ // allocation group.
+ parent_support().SubmitCompositorFrame(
+ parent_id.local_surface_id(),
+ MakeCompositorFrame({child_id1a}, empty_surface_ranges(),
+ std::vector<TransferableResource>(),
+ MakeDefaultDeadline()));
+ EXPECT_TRUE(parent_surface()->HasActiveFrame());
+ EXPECT_TRUE(parent_surface()->HasPendingFrame());
+ EXPECT_THAT(parent_surface()->activation_dependencies(),
+ UnorderedElementsAre(child_id1a));
+ // Submit a frame to |child_id1a| that depends on a surface that will never
+ // exist. It stays pending.
+ child_support1().SubmitCompositorFrame(
+ child_id1a.local_surface_id(),
+ MakeCompositorFrame({arbitrary_id}, empty_surface_ranges(),
+ std::vector<TransferableResource>(),
+ MakeDefaultDeadline()));
+ EXPECT_FALSE(child_surface1()->HasActiveFrame());
+ EXPECT_TRUE(child_surface1()->HasPendingFrame());
+ // Submit a frame to |child_id2| with no dependencies. Its activation
+ // notifies the parent, which recomputes its referenced surfaces. While
+ // processing the |child_id1a| reference, that child is force-activated which
+ // in turn resolves the dependency of the parent's pending frame, replacing
+ // the parent's active frame mid-iteration. This must not crash.
+ child_support2().SubmitCompositorFrame(
+ child_id2.local_surface_id(),
+ MakeCompositorFrame(empty_surface_ids(), empty_surface_ranges(),
+ std::vector<TransferableResource>()));
+
+ EXPECT_TRUE(child_surface1()->HasActiveFrame());
+ EXPECT_TRUE(parent_surface()->HasActiveFrame());
+ EXPECT_FALSE(parent_surface()->HasPendingFrame());
+ EXPECT_THAT(parent_surface()->activation_dependencies(), IsEmpty());
+}
+
// Tests that when a CompositorFrame for an Embedded Surface arrives after its
// Embedder has submitted new ActivationDependencies, that it is immediately
// ACKed, even if normally it would not be due to damage. This way we don't have