CVE-2026-93385
Overview
Background
- HTML-in-Canvas (HiC) / `drawElementImage`
- an experimental feature that renders a live DOM element (here an
<svg>inside a<canvas layoutsubtree>) into the 2D canvas bitmap, which script can then read back withgetImageData. - Privacy-preserving paint (`PaintInfo::IsPrivacyPreserving`)
- a paint mode used when snapshotting content into a canvas, in which privacy-sensitive rendering such as
:visitedlink styling and form-control autofill must be suppressed because the resulting pixels are script-readable. - SVG marker (`LayoutSVGResourceMarker`)
- a reusable SVG resource (referenced via
marker-start/marker-mid/marker-end) that can contain arbitrary drawable content, including images, filters, and<foreignObject>HTML. - `Element::IsInCanvasSubtree`
- a predicate that returns whether a DOM element lives inside a canvas’s HiC layout subtree, i.e. within the content region that the privacy-preserving snapshot actually covers.
Root Cause Analysis
When an SVG shape is painted during a privacy-preserving canvas snapshot, svg_shape_painter.cc resolves its marker-start, marker-mid, and marker-end references into LayoutSVGResourceMarker objects via GetSVGResourceAsType and paints them. Before the patch this resolution honored any marker the shape referenced, including markers defined by <marker> elements located outside the canvas subtree — content that is not part of, and not governed by, the privacy-preserving snapshot. Because a marker can embed arbitrary content (images, filters, or a <foreignObject> containing HTML whose appearance depends on :visited state or autofill), painting an out-of-subtree marker draws privacy-sensitive pixels into a readback-accessible canvas, breaking the invariant that only privacy-protected in-subtree content is rendered in this mode.
The fix adds a guard, active only when paint_info.IsPrivacyPreserving(), that nulls out any of the three markers whose defining GetElement() returns false for IsInCanvasSubtree(). This runs before the existing if (!marker_start && !marker_mid && !marker_end) return; short-circuit and before any downstream marker painting, so disallowed external markers are simply never drawn. It mirrors the protection already applied to SVG patterns.
Attack Path
- Set up a HiC canvas
The attacker page places a
<canvas layoutsubtree>containing an<svg>child and callsctx.drawElementImage(child, ...), triggering a privacy-preserving paint of the SVG subtree into the canvas. - Reference an external marker
A
<path>inside the in-canvas SVG usesmarker-start/marker-mid/marker-endpointing at a<marker>defined outside the canvas subtree, whose content (image, filter, or<foreignObject>) is not covered by privacy protections. - Embed privacy-sensitive content
The external marker draws content whose pixels depend on privacy-sensitive state such as
:visitedlink color or form-control autofill (inferred, as the concrete leak vector; the patch and comment name these as the protected classes). - Read back the pixels
After the marker paints into the canvas, the attacker calls
getImageDataand inspects pixel values (as the shipped test does at fixed coordinates) to recover the privacy-sensitive state.
Impact Assessment
drawElementImage feature is available and the attacker can script a canvas snapshot on their own page. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5. Any escalation beyond pixel-level privacy disclosure is not demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
promise_testthird_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-marker-outside-subtree-ignored.tentative.html |
modified |
Files Changed
third_party/blink/renderer/core/paint/svg_shape_painter.ccthird_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-marker-outside-subtree-ignored.tentative.html
Audit Directions
- Externally-referenced SVG resources during privacy-preserving paintAudit every SVG resource type resolved by reference (patterns, masks, clip-paths, filters, gradients,
paint-order,mask,use) for the same missingIsInCanvasSubtree/privacy check whenIsPrivacyPreserving()is set. - Canvas-subtree boundary enforcementLook for paint or layout code that follows resource IDs or
url(#...)references across the canvas-subtree boundary without confirming the target element is in-subtree, since such crossings can pull unprotected content into readback-accessible bitmaps. - New privacy-preserving sinksWherever
PaintInfo::IsPrivacyPreserving()gates behavior, verify the guard runs before every content-emitting path (not just the primary one) and that newly added paint features (like HiC) opt into it, since the check is easy to omit on secondary code paths.
Patch
From f32b64bb9703eccac76a8a6c891c90841eaf2ec2 Mon Sep 17 00:00:00 2001
From: Stephen Chenney <schenney@chromium.org>
Date: Thu, 10 Sep 2026 18:11:45 -0700
Subject: [PATCH] [HiC] Disallow SVG markers that are outside a canvas subtree
SVG Markers may contain arbitrary HTML content through a
<foreignObject>, not to mention a wide variety of images
or filters. As with SVG patterns, we disallow the use of
markers defined outside of a canvas subtree, where privacy
protections are not available, particularly for visited
links and form control autofill.
Fixed: 553136980
Change-Id: I51dd1ab951949b79824ac00ae27dc134c3bc12c9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8385141
Reviewed-by: Stefan Zager <szager@chromium.org>
Commit-Queue: Stephen Chenney <schenney@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1695874}
---
diff --git a/third_party/blink/renderer/core/paint/svg_shape_painter.cc b/third_party/blink/renderer/core/paint/svg_shape_painter.cc
index ea10cbf7..2e963f5 100644
--- a/third_party/blink/renderer/core/paint/svg_shape_painter.cc
+++ b/third_party/blink/renderer/core/paint/svg_shape_painter.cc
@@ -266,6 +266,20 @@
*client, style.MarkerMidResource());
auto* marker_end = GetSVGResourceAsType<LayoutSVGResourceMarker>(
*client, style.MarkerEndResource());
+ if (paint_info.IsPrivacyPreserving()) {
+ if (marker_start && marker_start->GetElement() &&
+ !marker_start->GetElement()->IsInCanvasSubtree()) {
+ marker_start = nullptr;
+ }
+ if (marker_mid && marker_mid->GetElement() &&
+ !marker_mid->GetElement()->IsInCanvasSubtree()) {
+ marker_mid = nullptr;
+ }
+ if (marker_end && marker_end->GetElement() &&
+ !marker_end->GetElement()->IsInCanvasSubtree()) {
+ marker_end = nullptr;
+ }
+ }
if (!marker_start && !marker_mid && !marker_end)
return;
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-marker-outside-subtree-ignored.tentative.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-marker-outside-subtree-ignored.tentative.html
new file mode 100644
index 0000000..23111f15
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-marker-outside-subtree-ignored.tentative.html
@@ -0,0 +1,82 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>drawElementImage does not use SVG markers outside the subtree</title>
+ <link rel="help" href="https://github.com/WICG/html-in-canvas">
+ <script src='/resources/testharness.js'></script>
+ <script src='/resources/testharnessreport.js'></script>
+ <script src='/html/canvas/resources/canvas-tests.js'></script>
+ <script src="/html/canvas/resources/wait-for-canvas-paint.js"></script>
+</head>
+<body>
+ <svg width="0" height="0">
+ <defs>
+ <marker id="markerOut" markerUnits="userSpaceOnUse" viewBox="0 0 100 100" markerWidth="20" markerHeight="20" refX="50" refY="50">
+ <image href="/images/red-100x100.png" x="0" y="0" width="100" height="100" preserveAspectRatio="none"/>
+ </marker>
+ </defs>
+ </svg>
+
+ <canvas id="canvas" width="100" height="100" layoutsubtree>
+ <svg id=child width="100" height="100">
+ <marker id="markerIn" markerUnits="userSpaceOnUse" viewBox="0 0 100 100" markerWidth="20" markerHeight="20" refX="50" refY="50">
+ <image href="/images/green-100x100.png" x="0" y="0" width="100" height="100" preserveAspectRatio="none"/>
+ </marker>
+ <path d="M 20 20 L 50 20 L 80 20"
+ stroke="black"
+ marker-start="url(#markerOut)"
+ marker-mid="url(#markerOut)"
+ marker-end="url(#markerOut)" />
+ <path d="M 20 40 L 50 40 L 80 40"
+ stroke="black"
+ marker-start="url(#markerIn)"
+ marker-mid="url(#markerIn)"
+ marker-end="url(#markerIn)" />
+ </svg>
+ </canvas>
+
+ <script>
+ window.onload = () => {
+ promise_test(async function(t) {
+ const preloadImage = (url) => new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = () => reject(new Error(`Failed to load image: ${url}`));
+ img.src = url;
+ });
+
+ const imageEl1 = document.querySelector('#markerOut image');
+ const imageEl2 = document.querySelector('#markerIn image');
+ await Promise.all([
+ preloadImage(imageEl1.getAttribute('href')),
+ preloadImage(imageEl2.getAttribute('href')),
+ ]);
+
+ await waitForCanvasPaint(canvas);
+ var ctx = canvas.getContext('2d');
+ ctx.fillStyle = 'white';
+ ctx.fillRect(0, 0, 100, 100);
+ ctx.drawElementImage(child, 0, 0);
+
+ // The canvas should ignore the marker and draw the line black.
+ // Fetch all pixel data once to avoid multiple slow readbacks.
+ const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+
+ let pixel = _getPixelFromImageData(imgData, 15, 25);
+ assert_array_equals(pixel, [255, 255, 255, 255], "External start marker should not draw");
+ pixel = _getPixelFromImageData(imgData, 45, 25);
+ assert_array_equals(pixel, [255, 255, 255, 255], "External mid marker should not draw");
+ pixel = _getPixelFromImageData(imgData, 85, 25);
+ assert_array_equals(pixel, [255, 255, 255, 255], "External end marker should not draw");
+
+ pixel = _getPixelFromImageData(imgData, 15, 45);
+ assert_array_equals(pixel, [0, 255, 0, 255], "Internal start marker should draw");
+ pixel = _getPixelFromImageData(imgData, 45, 45);
+ assert_array_equals(pixel, [0, 255, 0, 255], "Internal mid marker should draw");
+ pixel = _getPixelFromImageData(imgData, 85, 45);
+ assert_array_equals(pixel, [0, 255, 0, 255], "Internal end marker should draw");
+ });
+ }
+ </script>
+</body>
+</html>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-marker-outside-subtree-ignored.tentative.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-marker-outside-subtree-ignored.tentative.html
new file mode 100644
index 0000000..23111f15
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/draw-element-image/privacy/svg-marker-outside-subtree-ignored.tentative.html
@@ -0,0 +1,82 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>drawElementImage does not use SVG markers outside the subtree</title>
+ <link rel="help" href="https://github.com/WICG/html-in-canvas">
+ <script src='/resources/testharness.js'></script>
+ <script src='/resources/testharnessreport.js'></script>
+ <script src='/html/canvas/resources/canvas-tests.js'></script>
+ <script src="/html/canvas/resources/wait-for-canvas-paint.js"></script>
+</head>
+<body>
+ <svg width="0" height="0">
+ <defs>
+ <marker id="markerOut" markerUnits="userSpaceOnUse" viewBox="0 0 100 100" markerWidth="20" markerHeight="20" refX="50" refY="50">
+ <image href="/images/red-100x100.png" x="0" y="0" width="100" height="100" preserveAspectRatio="none"/>
+ </marker>
+ </defs>
+ </svg>
+
+ <canvas id="canvas" width="100" height="100" layoutsubtree>
+ <svg id=child width="100" height="100">
+ <marker id="markerIn" markerUnits="userSpaceOnUse" viewBox="0 0 100 100" markerWidth="20" markerHeight="20" refX="50" refY="50">
+ <image href="/images/green-100x100.png" x="0" y="0" width="100" height="100" preserveAspectRatio="none"/>
+ </marker>
+ <path d="M 20 20 L 50 20 L 80 20"
+ stroke="black"
+ marker-start="url(#markerOut)"
+ marker-mid="url(#markerOut)"
+ marker-end="url(#markerOut)" />
+ <path d="M 20 40 L 50 40 L 80 40"
+ stroke="black"
+ marker-start="url(#markerIn)"
+ marker-mid="url(#markerIn)"
+ marker-end="url(#markerIn)" />
+ </svg>
+ </canvas>
+
+ <script>
+ window.onload = () => {
+ promise_test(async function(t) {
+ const preloadImage = (url) => new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = () => reject(new Error(`Failed to load image: ${url}`));
+ img.src = url;
+ });
+
+ const imageEl1 = document.querySelector('#markerOut image');
+ const imageEl2 = document.querySelector('#markerIn image');
+ await Promise.all([
+ preloadImage(imageEl1.getAttribute('href')),
+ preloadImage(imageEl2.getAttribute('href')),
+ ]);
+
+ await waitForCanvasPaint(canvas);
+ var ctx = canvas.getContext('2d');
+ ctx.fillStyle = 'white';
+ ctx.fillRect(0, 0, 100, 100);
+ ctx.drawElementImage(child, 0, 0);
+
+ // The canvas should ignore the marker and draw the line black.
+ // Fetch all pixel data once to avoid multiple slow readbacks.
+ const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+
+ let pixel = _getPixelFromImageData(imgData, 15, 25);
+ assert_array_equals(pixel, [255, 255, 255, 255], "External start marker should not draw");
+ pixel = _getPixelFromImageData(imgData, 45, 25);
+ assert_array_equals(pixel, [255, 255, 255, 255], "External mid marker should not draw");
+ pixel = _getPixelFromImageData(imgData, 85, 25);
+ assert_array_equals(pixel, [255, 255, 255, 255], "External end marker should not draw");
+
+ pixel = _getPixelFromImageData(imgData, 15, 45);
+ assert_array_equals(pixel, [0, 255, 0, 255], "Internal start marker should draw");
+ pixel = _getPixelFromImageData(imgData, 45, 45);
+ assert_array_equals(pixel, [0, 255, 0, 255], "Internal mid marker should draw");
+ pixel = _getPixelFromImageData(imgData, 85, 45);
+ assert_array_equals(pixel, [0, 255, 0, 255], "Internal end marker should draw");
+ });
+ }
+ </script>
+</body>
+</html>