CVE-2026-113072
Overview
Background
- Largest Contentful Paint (LCP)
- a web-performance API; a
LargestContentfulPaintperformance entry (third_party/blink/renderer/core/timing/largest_contentful_paint.cc) exposes anelementattribute pointing at the DOM node responsible for the largest paint, observable from JavaScript viaPerformanceObserver. - `element_`
- the internal pointer the entry holds to the painting node. The public
element()getter decides whether to surface it to script. - `PseudoElement`
- a Blink render-tree object for CSS pseudo-elements such as
::before/::after. In BlinkPseudoElementis a subclass ofElement, but it is an internal object: it is not a real, author-visible DOM node and must never be handed to script as one. - `Performance::CanExposeNode`
- the shared Blink predicate (
core/timing/performance.h) that gates whether a node may be surfaced through performance APIs; among other checks, it excludes nodes that must not be script-exposed (including pseudo-elements). - querySelector cache
- Blink caches selector-query results on the document; mutating the tree (e.g. via slot re-assignment) while such a cache is being traversed is an unsafe re-entrant modification.
Root Cause Analysis
LargestContentfulPaint::element() originally gated exposure with if (!element_ || !element_->isConnected() || element_->IsInShadowTree()) return nullptr;. That check filtered out disconnected and shadow-tree nodes but did not filter out PseudoElements. When the largest contentful paint was a CSS pseudo-element (e.g. a ::before with display:block content), element_ held a PseudoElement, and the getter returned it to JavaScript as if it were an ordinary Element. Script could then treat this internal render-tree object as a real DOM element — reparenting it, setting attributes, and otherwise driving DOM-mutation machinery that pseudo-elements are never supposed to reach. Per the commit, the concretely reported consequence was modifying querySelector caches while they were being traversed (via slot re-assignment), i.e. re-entrant tree mutation during a cache walk — the memory-corruption sink the advisory refers to.
The patch replaces the hand-rolled condition with the centralized predicate: if (!Performance::CanExposeNode(element_)) return nullptr;, and includes core/timing/performance.h. CanExposeNode subsumes the null/connected checks and additionally rejects pseudo-elements (and other non-exposable nodes), so the LCP entry no longer leaks a PseudoElement to script. A WPT regression test (observe-pseudo-element.tentative.html) asserts that a target whose LCP is a ::before reports the real #target element, not the pseudo-element. Note the scope of this diff: it substantiates the exposure root cause — an internal object reaching script through a getter typed Element* — but the downstream memory-corruption primitive (querySelector-cache mutation during traversal / slot re-assignment) lives in other DOM code and is not present in this diff; the advisory labels this “memory corruption in CSS,” which this diff addresses only by closing the exposure that enables it.
element() getter filtered for connected, non-shadow nodes but never for pseudo-elements, so a CSS pseudo-element could be handed to JavaScript as a real Element — giving script a mutation handle onto an internal render-tree object that was never meant to be scriptable.Attack Path
- Web attacker
serves a page that styles a target with a block-level pseudo-element (
#target::before { content: ...; display: block; }) so the pseudo-element becomes the largest contentful paint (inferred: standard same-origin page content). - Observe LCP
registers a
PerformanceObserverforlargest-contentful-paintand readsentry.element, which under the old code returns thePseudoElement. - Obtain the handle
script now holds a live reference to an internal pseudo-element object treated as an
Element. - Mutate illegally script moves the pseudo-element in the tree / sets attributes / triggers slot re-assignment, driving DOM mutation that re-enters a querySelector cache while it is being traversed.
- Primitive re-entrant modification of Blink DOM caches during traversal (advisory: memory corruption); the exact corruption (use-after-free / invalidated-iterator class) is described by the author but its sink code is outside this diff, so the primitive’s strength is (inferred) from the commit message rather than proven here.
Impact Assessment
PseudoElement leaking to script through an Element*-typed getter. The advisory’s “memory corruption” is the downstream effect: once script can mutate a pseudo-element, it can perform unsafe re-entrant tree mutations (the reported querySelector-cache-during-traversal / slot-re-assignment case), which is the dangerous primitive. However, this diff only closes the exposure; it does not touch the corruption sink, so the memory-corruption class is substantiated by the commit narrative, not by code in this patch. Honest strength: confirmed script-exposure of an internal object (a clear security bug) enabling renderer memory corruption whose precise write primitive is not demonstrated here.Files Changed
third_party/blink/renderer/core/timing/largest_contentful_paint.ccthird_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative-expected.txtthird_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative.html
Audit Directions
- Other performance-entry `element`/node gettersaudit
LayoutShift,element-timing(PerformanceElementTiming), event-timingtarget, and any other API that surfaces a DOM node from timing/paint data; confirm each routes throughCanExposeNoderather than an ad-hoc connected/shadow check that misses pseudo-elements. - `Element`-typed APIs that may hold a `PseudoElement`look for any binding getter typed
Element*whose backing pointer can be aPseudoElement, where script gaining the handle would allow illegal DOM mutation. - Re-entrant DOM-cache mutationreview querySelector/
:has()/selector caches and slot re-assignment for safety against mutation during traversal; the exposure here is one entry point, but the underlying cache-mutation sink is the memory-safety hazard and may be reachable by other means.
Patch
From 73dd2febfaa5d8d2a398dbd906454a4e7e9d2b79 Mon Sep 17 00:00:00 2001
From: Rune Lillesveen <futhark@chromium.org>
Date: Tue, 13 Oct 2026 15:29:08 -0700
Subject: [PATCH] Do not expose pseudo-elements through LargestContentfulPaint API
If a PseudoElement is stored as the element_ in the
LargestContentfulPaint object, we would expose that to javascript
through the element attribute.
This could cause all sorts of problems, letting javascript be able to
modify them as if they were true elements, moving them around the tree
in unexpected places and setting attributes on them, for instance.
Specifically, the issue reported was modifying querySelector caches
while being traversed due to slot re-assignment.
Bug: 570638836
Change-Id: I44d87a537a7da52fa9dc2aa577a58a888c94f3de
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8545831
Commit-Queue: Scott Haseley <shaseley@chromium.org>
Commit-Queue: Rune Lillesveen <futhark@chromium.org>
Auto-Submit: Rune Lillesveen <futhark@chromium.org>
Reviewed-by: Scott Haseley <shaseley@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1718007}
---
diff --git a/third_party/blink/renderer/core/timing/largest_contentful_paint.cc b/third_party/blink/renderer/core/timing/largest_contentful_paint.cc
index 289f288..0e91d2f 100644
--- a/third_party/blink/renderer/core/timing/largest_contentful_paint.cc
+++ b/third_party/blink/renderer/core/timing/largest_contentful_paint.cc
@@ -7,6 +7,7 @@
#include "third_party/blink/renderer/bindings/core/v8/v8_object_builder.h"
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/performance_entry_names.h"
+#include "third_party/blink/renderer/core/timing/performance.h"
#include "third_party/blink/renderer/platform/wtf/text/atomic_string.h"
namespace blink {
@@ -43,8 +44,9 @@
}
Element* LargestContentfulPaint::element() const {
- if (!element_ || !element_->isConnected() || element_->IsInShadowTree())
+ if (!Performance::CanExposeNode(element_)) {
return nullptr;
+ }
// Do not expose |element_| when the document is not 'fully active'.
const Document& document = element_->GetDocument();
diff --git a/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative-expected.txt b/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative-expected.txt
new file mode 100644
index 0000000..95542369
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative-expected.txt
@@ -0,0 +1,5 @@
+This is a testharness.js-based test.
+[FAIL] largest-contentful-paint should not expose pseudo-elements
+ assert_equals: expected Element node <div id="target"></div> but got null
+Harness: the test ran to completion.
+
diff --git a/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative.html b/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative.html
new file mode 100644
index 0000000..e190aa42
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<title>Largest Contentful Paint Test: pseudo-elements should not be exposed to javascript</title>
+<link rel="help" href="https://w3c.github.io/largest-contentful-paint">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+ #target::before {
+ content: "Content";
+ display: block;
+ }
+</style>
+<div id="target"></div>
+<script>
+ async_test(t => {
+ new PerformanceObserver(l => {
+ t.step(() => {
+ assert_equals(l.getEntries()[0].element, target);
+ }, "#target is reported as node for lcp");
+ t.done();
+ }).observe({ type: 'largest-contentful-paint' });
+ }, "largest-contentful-paint should not expose pseudo-elements");
+</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative-expected.txt b/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative-expected.txt
new file mode 100644
index 0000000..95542369
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative-expected.txt
@@ -0,0 +1,5 @@
+This is a testharness.js-based test.
+[FAIL] largest-contentful-paint should not expose pseudo-elements
+ assert_equals: expected Element node <div id="target"></div> but got null
+Harness: the test ran to completion.
+
diff --git a/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative.html b/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative.html
new file mode 100644
index 0000000..e190aa42
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/largest-contentful-paint/observe-pseudo-element.tentative.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<title>Largest Contentful Paint Test: pseudo-elements should not be exposed to javascript</title>
+<link rel="help" href="https://w3c.github.io/largest-contentful-paint">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<style>
+ #target::before {
+ content: "Content";
+ display: block;
+ }
+</style>
+<div id="target"></div>
+<script>
+ async_test(t => {
+ new PerformanceObserver(l => {
+ t.step(() => {
+ assert_equals(l.getEntries()[0].element, target);
+ }, "#target is reported as node for lcp");
+ t.done();
+ }).observe({ type: 'largest-contentful-paint' });
+ }, "largest-contentful-paint should not expose pseudo-elements");
+</script>