CVE-2026-87651
Overview
Background
- HTML-in-Canvas (HiC) / `drawElementImage`
- an experimental Canvas API, gated by the
CanvasDrawElementruntime feature and shippable as an origin trial, that lets a page draw live DOM elements (with their CSS, including filters) into a canvas. - Origin-tainted filter
- a CSS filter whose
operations.OriginTainted()is true because it references cross-origin content; painting it inside a canvas subtree could leak cross-origin pixels, so it must be stripped. - `state.is_in_drawable_canvas_subtree`
- a flag carried in the paint-property-tree-builder fragment context that records whether the object being walked lives in a canvas-drawable subtree, independent of any per-frame feature flag.
Root Cause Analysis
During paint property tree construction, the code that decides whether an origin-tainted filter (and backdrop-filter) must be removed computed is_filter_disallowed by first calling RuntimeEnabledFeatures::CanvasDrawElementEnabled(object_.GetDocument().GetExecutionContext()). That feature check is resolved against the execution context of the frame currently being painted, so a main frame that never enabled HTML-in-Canvas returns false even though a nested same-origin subframe legitimately enabled the origin trial through its subframe-request headers. When the subframe used drawElementImage with cross-origin filter content, the main frame’s painting code short-circuited the disallow check to false, retained the origin-tainted filter, and painted it — violating the invariant that cross-origin filters must always be stripped inside a canvas-drawn subtree regardless of which frame reports the feature enabled.
The fix replaces the flag-and-IsInCanvasSubtree() test with state.is_in_drawable_canvas_subtree, which reflects the actual canvas subtree state carried through the tree walk, so the tainted filter is stripped whenever it is inside a drawable canvas subtree. The removed comment/lines confirm the old behavior deferred to the feature flag; the change is a broadening of the stripping check, not a tightening of an allow-list.
CanvasDrawElementEnabled evaluated on the main frame’s execution context) instead of on the actual canvas-subtree state, letting a same-origin subframe that opted into the origin trial bypass filter stripping in the parent’s paint tree. The fix keys the decision on state.is_in_drawable_canvas_subtree, which is true wherever the content is actually in a drawable canvas subtree, so the flag mismatch between frames can no longer suppress stripping.Attack Path
- Host a main frame without HiC
The attacker serves a top-level (or intermediate) page that does not enable the HTML-in-Canvas origin trial, so
CanvasDrawElementEnabledisfalsefor that frame’s execution context. - Embed a same-origin subframe that opts in
A nested same-origin iframe is loaded whose subframe response headers enable the
CanvasDrawElementorigin trial, making the featuretrueonly for the subframe. - Draw cross-origin filter content
The subframe calls
drawElementImageon an element carrying an origin-tainted CSS filter (one whoseoperations.OriginTainted()is true because it references cross-origin content). - Parent paint skips stripping
When the paint property tree builder computes
is_filter_disallowed, the main-frame feature check returnsfalse, sois_filter_disallowedisfalse,filter_info/backdrop_filter_infois populated, and the tainted filter is painted instead of removed. - Cross-origin content is exposed The retained tainted filter renders cross-origin content that should have been blocked, leaking information across the origin boundary (inferred extent — the diff shows the removal is skipped but does not itself demonstrate the exact readback channel).
Impact Assessment
drawElementImage with tainted filter content. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. Any escalation beyond disclosure of the tainted filter content is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/paint/paint_property_tree_builder.cc |
modified |
Files Changed
third_party/blink/renderer/core/paint/paint_property_tree_builder.cc
Audit Directions
- Feature-flag checks as security gatesAudit any place a
RuntimeEnabledFeatures::*Enabled(ExecutionContext*)(especially origin-trial-backed features) is ANDed into a cross-origin or authorization decision; the flag can differ between a frame and its same-origin subframes, so the security check should key on structural/subtree state, not the flag. - Cross-frame paint decisionsLook for paint, hit-testing, or property-tree code that reads
object_.GetDocument().GetExecutionContext()to decide whether to strip or expose content, since the “current object” frame may not be the frame that produced the content being painted. - Origin-taint stripping pathsReview other consumers of
operations.OriginTainted()/IsInCanvasSubtree()(filters, backdrop-filters, masks, clip-paths) to confirm each stripping decision fires on canvas-subtree state consistently rather than on a feature flag that a subframe can independently enable.
Patch
From d514b24be816134378c44660782560b16685a29e Mon Sep 17 00:00:00 2001
From: Stephen Chenney <schenney@chromium.org>
Date: Mon, 31 Aug 2026 19:44:38 -0700
Subject: [PATCH] [HiC] Fix privacy leaks due to feature flag checks
When a frame that does not enable HTML-in-Canvas embeds a same-origin
iframe that does enable the origin trial (through headers in the
subframe request) the sub-frame can use drawElementImage with
cross-origin filter content and the checks in the main frame painting
code will not remove the filter, because the main frame does not think
HTML-in-Canvas is enabled.
Fix the cross-origin filter removal code to check for canvas subtree
instead of using the feature flag. While this will remove filters in
canvas subtrees even if HTML-in-Canvas is not enabled, it doesn't matter
because the filters are not exposed to a11y and there is otherwise no
way to see them.
Bug: 550141694
Change-Id: I3dc8f8d9f157ee2a3f73851687a04d537b1dcef9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8310987
Commit-Queue: Stephen Chenney <schenney@chromium.org>
Reviewed-by: Stefan Zager <szager@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1689553}
---
diff --git a/third_party/blink/renderer/core/paint/paint_property_tree_builder.cc b/third_party/blink/renderer/core/paint/paint_property_tree_builder.cc
index f4c17437..d1a242b 100644
--- a/third_party/blink/renderer/core/paint/paint_property_tree_builder.cc
+++ b/third_party/blink/renderer/core/paint/paint_property_tree_builder.cc
@@ -2636,9 +2636,7 @@
UpdateFilterEffect(object_, properties_->Filter(), filter_info);
bool is_filter_tainted = filter_info.operations.OriginTainted();
bool is_filter_disallowed =
- RuntimeEnabledFeatures::CanvasDrawElementEnabled(
- object_.GetDocument().GetExecutionContext()) &&
- object_.IsInCanvasSubtree() && is_filter_tainted;
+ state.is_in_drawable_canvas_subtree && is_filter_tainted;
if (!(filter_info.operations.IsEmpty() || is_filter_disallowed)) {
state.filter_info =
std::make_unique<EffectPaintPropertyNode::FilterInfo>(
@@ -4416,9 +4414,7 @@
}
if (!operations.IsEmpty()) {
bool is_filter_disallowed =
- RuntimeEnabledFeatures::CanvasDrawElementEnabled(
- object_.GetDocument().GetExecutionContext()) &&
- object_.IsInCanvasSubtree() && operations.OriginTainted();
+ state.is_in_drawable_canvas_subtree && operations.OriginTainted();
if (!is_filter_disallowed) {
state.backdrop_filter_info =
base::WrapUnique(new EffectPaintPropertyNode::BackdropFilterInfo{