← Chrome — 2026-W03

07e904d1194a437bdea9b09bd6b63f86ef89a6bd  Ensure we don't recompute visual overflow in locked subtrees.

severity medium class renderer-crash/bad-state CVSS 6.5 confidence 0.5 signal score 4.5 third_party/blink/renderer AI review CONFIRMED
Primitive: PaintLayer visual-overflow recalc walked into content-visibility-locked subtrees; fix defers the recalc (NotifyVisualOverflowRecalcWasBlocked) until unlock instead of operating on locked/incomplete layout.
Triage note: Renderer crash reachable via content-visibility plus SVG foreignObject in default config (regression from crrev/762550). Test asserts no-crash; no confirmed memory corruption, so watch.

The bug at a glance

A web-reachable, deterministic renderer bad-state: recomputing PaintLayer visual overflow inside a content-visibility-locked subtree operates on intentionally incomplete/skipped layout. The shipped SVG foreignObject regression test asserts ‘DoesNotCrash’, which in a DCHECK-enabled build means a reachable assertion/NOTREACHED-class failure; in release it is a computation over stale geometry rather than a memory-corruption primitive. No pointer is freed, no OOB write is demonstrated, and the author marks exploitable=false. That caps it below high. It sits above low because it is a genuine engine invariant violation (locked subtrees must not have their overflow recomputed) reachable from ordinary markup (contain / content-visibility plus a self-painting layer), and stale visual-overflow geometry feeds paint/hit-test/compositing, so a follow-on correctness bug touching those rects is plausible. Medium / 6.5 is honest.

content-visibility (display-lock) is a rich source of layout-invariant bugs because it deliberately leaves subtrees in a half-computed state, and any code path that forgets to consult the lock will read or mutate geometry that was never finished. This CL is the second attempt at the same invariant (crrev.com/762550 was the first), which is itself the interesting signal: the class of ‘recompute X in a locked subtree’ bugs was known, a guard existed, and a gap remained. The angle for an auditor is every remaining PaintLayer / prepaint code path that recalculates cached geometry without first checking ChildPrePaintBlockedByDisplayLock().

Root cause

During the pre-paint tree walk, PaintLayer::UpdateDescendantDependentFlags checks needs_visual_overflow_recalc_ and, for a self-painting layer, unconditionally called GetLayoutObject().RecalcVisualOverflow(). RecalcVisualOverflow recurses into children to sum their overflow. When the layer’s layout object has ChildPrePaintBlockedByDisplayLock() true (a content-visibility:hidden/auto locked element whose descendants were skipped during layout), those descendants’ layout/overflow is intentionally not up to date, so the recursion walks into skipped/incomplete subtree state. The shipped test VisualOverflowCalculateOnDescendantOfLockedSVGDoesNotCrash reproduces it: an SVG and a nested are both LockImmediate’d; the foreignObject gets a self-painting PaintLayer while the outer does not, and calling SetNeedsVisualOverflowRecalc() on the inner layer then running the lifecycle drives the recalc into the locked SVG subtree. The fix makes the recalc lock-aware: if ChildPrePaintBlockedByDisplayLock(), instead of recomputing it calls GetDisplayLockContext()->NotifyVisualOverflowRecalcWasBlocked(), which sets needs_visual_overflow_recalc_update_. When the element is later unlocked, MarkAncestorsfor-prepaint machinery calls the new MarkForVisualOverflowRecalcIfNeeded(), which re-arms the layer via Layer()->SetNeedsVisualOverflowRecalc() so the overflow is finally computed against complete layout. The second shipped test VisualOverflowUpdateAfterUnlock proves the deferral is correct end-to-end: a box-shadow change under a lock is deferred, and after SetRequestedState(kVisible) the VisualOverflowRect updates from 110x110 to 120x120.

Key code

paint_layer.cc — the recalc becomes display-lock-aware; deferral replaces the unconditional recompute

    if (IsSelfPaintingLayer() && needs_visual_overflow_recalc_) {
-      PhysicalRect old_visual_rect =
-          PhysicalVisualOverflowRectAllowingUnset(GetLayoutObject());
-      GetLayoutObject().RecalcVisualOverflow();
-      if (old_visual_rect != GetLayoutObject().VisualOverflowRect()) {
-        MarkAncestorChainForFlagsUpdate(kDoesNotNeedDescendantDependentUpdate);
+      if (GetLayoutObject().ChildPrePaintBlockedByDisplayLock()) {
+        GetLayoutObject()
+            .GetDisplayLockContext()
+            ->NotifyVisualOverflowRecalcWasBlocked();
+      } else {
+        PhysicalRect old_visual_rect =
+            PhysicalVisualOverflowRectAllowingUnset(GetLayoutObject());
+        GetLayoutObject().RecalcVisualOverflow();
+        if (old_visual_rect != GetLayoutObject().VisualOverflowRect()) {
+          MarkAncestorChainForFlagsUpdate(
+              kDoesNotNeedDescendantDependentUpdate);
+        }
+      }
    }
    needs_visual_overflow_recalc_ = false;

Patch walkthrough

  • third_party/blink/renderer/core/paint/paint_layer.cc — The actual bug site. UpdateDescendantDependentFlags gains a ChildPrePaintBlockedByDisplayLock() branch. If the subtree is locked, it no longer recomputes overflow (which would walk incomplete layout); it records that the recalc was blocked so it can run later. needs_visual_overflow_recalc_ is still cleared in both branches, so the layer is not left perpetually dirty.
  • third_party/blink/renderer/core/display_lock/display_lock_context.cc — Adds MarkForVisualOverflowRecalcIfNeeded(): if the element is connected and needs_visual_overflow_recalc_update_ is set, and its layout object is a LayoutBoxModelObject with a self-painting layer, it re-arms the layer with SetNeedsVisualOverflowRecalc() and clears the flag. It is wired into MarkAncestorsForPrePaintIfNeeded so the deferred recalc is picked up on the next prepaint after unlock, and its result is OR-ed into the return so the caller knows work was scheduled.
  • third_party/blink/renderer/core/display_lock/display_lock_context.h — Declares the new bool needs_visual_overflow_recalc_update_ (default false), the public NotifyVisualOverflowRecalcWasBlocked() setter called from paint_layer.cc, and the private MarkForVisualOverflowRecalcIfNeeded() method. This is the persistent state that bridges ‘blocked during lock’ to ‘run on unlock’.
  • third_party/blink/renderer/core/display_lock/display_lock_context_test.cc — Two regression tests. The SVG foreignObject one is the crash reproducer (nested locks, inner self-painting layer). The box-shadow one verifies correctness of deferral: overflow does not change while locked and is correctly recomputed (120x120) after transitioning to content-visibility:visible.

Background

content-visibility / display lock — content-visibility:hidden/auto lets the engine skip layout and paint of an element’s subtree. Internally a DisplayLockContext tracks the locked state and which lifecycle work was blocked; ChildPrePaintBlockedByDisplayLock() is true when descendant prepaint was skipped.

Visual overflow — The union of an element’s own ink/geometry plus its descendants’ overflow (box-shadow, outlines, transformed children, etc.). Cached on the layout object; PaintLayer marks it dirty via SetNeedsVisualOverflowRecalc and recomputes during the descendant-dependent-flags pass of prepaint.

Self-painting layer — A PaintLayer that paints itself and manages its own overflow. Only self-painting layers reach the recalc branch; note in the SVG test the outer has no layer while the inner does, which is why the recursion crosses the lock boundary.

crrev.com/762550 — The original fix for ‘don’t recompute visual overflow in locked subtrees.’ This CL is the acknowledged sequel closing residual paths, per the commit message.

Vulnerability window

  1. origin — First guard against recomputing visual overflow in locked subtrees landed as crrev.com/762550; it did not cover all layer configurations (e.g. self-painting layer whose child prepaint is blocked).
  2. report — crbug.com/358041228 filed; the SVG-foreignObject / nested-lock configuration still drove RecalcVisualOverflow into locked layout, tripping a crash/assert.
  3. fix — 2026-01-15, Philip Rogers lands 07e904d1 (m@{#1569936}); recalc becomes lock-aware and defers to unlock.
  4. disclosure — Silent security-relevant fix (isSecurity=true, exploitable=false); shipped with public regression tests, consistent with watch-tier hardening of a known invariant.

Proof of concept

The commit ships a C++ unit-test reproducer, not a browser exploit. VisualOverflowCalculateOnDescendantOfLockedSVGDoesNotCrash constructs nested content-visibility locks on an SVG and , confirms only the inner element gets a PaintLayer, marks that layer’s visual overflow dirty, and runs the lifecycle; pre-fix this drove RecalcVisualOverflow into the locked subtree and crashed/asserted. A pure-markup recipe (no exploit demonstrated, none possible from this data): create an element with contain:layout/style or content-visibility that owns a self-painting layer, nest a locked child with skipped prepaint, then mutate a property that changes visual overflow (box-shadow/outline) while locked so SetNeedsVisualOverflowRecalc fires on the next prepaint. There is no memory-corruption primitive here; the reproducer only demonstrates a crash/bad-state, so no exploit code is warranted or invented.

TEST_F(DisplayLockContextRenderingTest,
       VisualOverflowCalculateOnDescendantOfLockedSVGDoesNotCrash) {
  SetHtmlInnerHTML(R"HTML(
    <svg width=100 height=100>
      <g id=outer_lock>
        <foreignObject id=inner_lock width=100 height=100>
          <div id=child style="width: 50px; height: 50px; background: blue;"></div>
        </foreignObject>
      </g>
    </svg>
  )HTML");
  auto* outer = GetDocument().getElementById(AtomicString("outer_lock"));
  auto* inner = GetDocument().getElementById(AtomicString("inner_lock"));
  LockImmediate(&outer->EnsureDisplayLockContext());
  LockImmediate(&inner->EnsureDisplayLockContext());
  UpdateAllLifecyclePhasesForTest();
  EXPECT_FALSE(outer->GetLayoutObject()->HasLayer());
  auto* inner_layer = GetPaintLayerByElementId("inner_lock");
  ASSERT_TRUE(inner_layer);
  inner_layer->SetNeedsVisualOverflowRecalc();
  UpdateAllLifecyclePhasesForTest();  // Ensure no crash.
}

Exploitation

  1. trigger — Deliver markup that puts a self-painting layer inside a content-visibility-locked subtree, then dirty its visual overflow (style mutation) so the prepaint recalc fires while locked. Fully script-reachable, no privilege.
  2. reached primitive — Pre-fix: RecalcVisualOverflow recurses across the lock boundary into skipped/incomplete layout, yielding an assert/crash (DCHECK builds) or stale overflow rects (release). This is a correctness/liveness bug, not a memory-safety primitive.
  3. escalation — No demonstrated path to control flow or corruption. Stale visual-overflow geometry can mis-drive paint/hit-test/cull-rect, so a chained correctness bug in a downstream consumer is the theoretical ceiling; nothing in the diff evidences that.

Detection & hunting

For defenders and detection engineers:

  • Renderer crash/DCHECK in visual-overflow recalc —
  • Rendering artifacts around content-visibility —

Audit directions

  • Other cached-geometry recalcs in prepaint —
  • Deferral completeness —
  • SVG + foreignObject lock interactions —

Before / after

Loading diff…