CVE-2026-79016
Overview
Background
- Built-in filter inputs
- SVG filter primitives can take the keywords
SourceGraphic,SourceAlpha,FillPaintandStrokePaintasin/in2.SVGFilterBuilderturns them into effects in itsbuiltin_effects_map;FillPaint/StrokePaintarePaintFilterEffects built from the current fill/stroke flags. - Origin-tainted (`OriginTainted()` / `SetOriginTainted()`)
- a flag on Blink
FilterEffects saying the effect’s pixels come from cross-origin content. Effects whose geometry depends on input pixel values use it to switch off that behaviour. - `feDisplacementMap`
- a primitive that moves
inpixels by amounts read from the channels ofin2. Per the new WPT tests, a taintedin2should make the displacement “have no effect”, so cross-origin pixel values cannot control geometry. - Canvas `filter` with `url(#id)`
CanvasRenderingContext2D.filtercan point to an SVG<filter>, so a page’s own canvas drawing (which may include cross-origin images or patterns) is fed through SVG filter primitives.
Root Cause Analysis
When svg_filter_builder.cc set up the built-in effects, it created SourceAlpha from SourceGraphic and FillPaint/StrokePaint as PaintFilterEffects from the current paint flags, but never copied SourceGraphic’s origin-tainted state onto them. So when the canvas SourceGraphic was tainted by cross-origin content, its alpha-derived sibling and the paint sources were reported as clean. Primitives that check taint, such as feDisplacementMap with in2="SourceAlpha", "FillPaint" or "StrokePaint", then used those cross-origin-derived pixels as displacement data. That broke the rule that every effect derived from tainted input is itself tainted.
The patch, placed right before AddBuiltinEffects(), loops over builtin_effects_ and calls SetOriginTainted() on each entry when source_graphic->OriginTainted() is true.
The patch comment admits this is a conservative assumption: it is exact for SourceAlpha and fits current FillPaint/StrokePaint users (<canvas>), where a cross-origin paint also taints SourceGraphic.
SourceGraphic but not passed to the built-in inputs derived from it (SourceAlpha) or alongside it (FillPaint/StrokePaint), so taint-aware primitives treated cross-origin pixels as safe. The fix copies the SourceGraphic taint to every built-in effect.Attack Path
- Load cross-origin image data into a canvas
A web page draws a cross-origin image, or uses it as a
createPattern()fill/stroke style, which taints the canvasSourceGraphic. Direct readback such asgetImageDatais still blocked by canvas origin-clean rules. - Apply an SVG filter that reads a built-in input
The page sets
context.filter = 'url(#disp)', pointing to anfeDisplacementMapwhosein2isSourceAlpha,FillPaintorStrokePaint, as in the shipped WPT tests. - Displacement uses cross-origin pixels Before the fix, those inputs were not marked tainted, so the displacement was applied with offsets from cross-origin pixel values, where it should have had no effect.
- Observe the discrepancy The cross-origin content now shapes rendering in a way the page can observe outside the blocked readback path, for example through timing or other side channels on the filtered output (inferred; the patch shows only the missing taint, not a specific readout channel).
Impact Assessment
(inferred). The advisory gives no in-the-wild exploitation status.Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/svg/graphics/filters/svg_filter_builder.cc |
modified |
Files Changed
third_party/blink/renderer/core/svg/graphics/filters/svg_filter_builder.ccthird_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/reference/green-100x100.htmlthird_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-fillpaint.sub.htmlthird_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-strokepaint.sub.htmlthird_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-sourcealpha.sub.html
Audit Directions
- Taint propagation across derived effectsCheck every place that builds a
FilterEffectfrom another effect or from paint flags (PaintFilterEffect,SourceAlpha,feImage,feTile, offscreen/paint-worklet sources) and confirm thatOriginTainted()is propagated, not only set on the original source. - Consumers that branch on taintList the primitives that change behaviour on taint (
feDisplacementMapand any lighting or convolution special cases) and check that they check every input (inandin2) after propagation, not onlySourceGraphic. - Non-canvas paint sourcesThe fix relies on
FillPaint/StrokePainttaint matchingSourceGraphicfor<canvas>. Check CSS/SVG element filters, where paint servers such as cross-origin<pattern>images could be tainted whileSourceGraphicis not.
Patch
From 0230e12ab23670f4004ee67f48896e8a68a7131e Mon Sep 17 00:00:00 2001
From: Fredrik Söderquist <fs@opera.com>
Date: Fri, 24 Jul 2026 03:49:43 -0700
Subject: [PATCH] Ensure FillPaint, StrokePaint and SourceAlpha sources are tainted
These sources would not get the tainted flag set if the canvas was
tainted.
Fixed: 497854976
Change-Id: If8217e5acfb0106b2b58a7234011ccfc39d7a83a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8121147
Reviewed-by: Philip Rogers <pdr@chromium.org>
Commit-Queue: Fredrik Söderquist <fs@opera.com>
Cr-Commit-Position: refs/heads/main@{#1667769}
---
diff --git a/third_party/blink/renderer/core/svg/graphics/filters/svg_filter_builder.cc b/third_party/blink/renderer/core/svg/graphics/filters/svg_filter_builder.cc
index 30dd5f7..4a81812 100644
--- a/third_party/blink/renderer/core/svg/graphics/filters/svg_filter_builder.cc
+++ b/third_party/blink/renderer/core/svg/graphics/filters/svg_filter_builder.cc
@@ -122,6 +122,14 @@
MakeGarbageCollected<PaintFilterEffect>(
source_graphic->GetFilter(), *stroke_flags));
}
+ // If SourceGraphic is tainted, we assume that all other built-in effects are
+ // tainted as well. This is obviously true for SourceAlpha, and works out for
+ // all current users that pass values for {Fill,Stroke}Paint (i.e <canvas>).
+ if (source_graphic->OriginTainted()) {
+ for (auto& entry : builtin_effects_) {
+ entry.value->SetOriginTainted();
+ }
+ }
AddBuiltinEffects();
}
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/reference/green-100x100.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/reference/green-100x100.html
new file mode 100644
index 0000000..a3ab3bb
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/reference/green-100x100.html
@@ -0,0 +1,2 @@
+<!doctype html>
+<div style="width: 100px; height: 100px; background-color: green"></div>
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-fillpaint.sub.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-fillpaint.sub.html
new file mode 100644
index 0000000..d614ef1
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-fillpaint.sub.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<title>SVG <feDisplacementMap> filter using a cross-origin FillPaint has no effect</title>
+<script src="/common/reftest-wait.js"></script>
+<link rel="match" href="reference/green-100x100.html">
+<canvas id="canvas" width="100" height="100">
+<svg width="0" height="0">
+ <filter id="disp">
+ <feDisplacementMap in="SourceGraphic" in2="FillPaint" scale="100"
+ yChannelSelector="R" xChannelSelector="G"/>
+ </filter>
+</svg>
+<script>
+ const img = new Image();
+ img.onload = () => {
+ const context = canvas.getContext('2d');
+ context.fillStyle = 'red';
+ context.fillRect(0, 0, 100, 100);
+
+ context.fillStyle = context.createPattern(img, 'repeat');
+ context.strokeStyle = 'green';
+ context.lineWidth = 50;
+ context.filter = 'url(#disp)';
+ context.strokeRect(25, 25, 50, 50);
+
+ takeScreenshot();
+ };
+ img.onerror = takeScreenshot;
+ img.src = 'https://{{hosts[alt][www]}}:{{ports[https][0]}}/images/grgr-256x256.png';
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-sourcealpha.sub.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-sourcealpha.sub.html
new file mode 100644
index 0000000..46957b3
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-sourcealpha.sub.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<title>SVG <feDisplacementMap> filter using a tainted SourceAlpha has no effect</title>
+<script src="/common/reftest-wait.js"></script>
+<link rel="match" href="reference/green-100x100.html">
+<canvas id="canvas" width="100" height="100">
+<svg width="0" height="0">
+ <filter id="disp">
+ <feDisplacementMap in="SourceGraphic" in2="SourceAlpha" scale="100"
+ yChannelSelector="A"/>
+ </filter>
+</svg>
+<script>
+ const img = new Image();
+ img.onload = () => {
+ const context = canvas.getContext('2d');
+ context.drawImage(img, 0, 0);
+ context.fillStyle = 'red';
+ context.fillRect(0, 0, 100, 100);
+
+ context.fillStyle = 'green';
+ context.filter = 'url(#disp)';
+ context.fillRect(0, 0, 100, 100);
+
+ takeScreenshot();
+ };
+ img.onerror = takeScreenshot;
+ img.src = 'https://{{hosts[alt][www]}}:{{ports[https][0]}}/images/grgr-256x256.png';
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-strokepaint.sub.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-strokepaint.sub.html
new file mode 100644
index 0000000..d2e7b0d4
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-strokepaint.sub.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<title>SVG <feDisplacementMap> filter using a cross-origin StrokePaint has no effect</title>
+<script src="/common/reftest-wait.js"></script>
+<link rel="match" href="reference/green-100x100.html">
+<canvas id="canvas" width="100" height="100">
+<svg width="0" height="0">
+ <filter id="disp">
+ <feDisplacementMap in="SourceGraphic" in2="StrokePaint" scale="100"
+ yChannelSelector="R" xChannelSelector="G"/>
+ </filter>
+</svg>
+<script>
+ const img = new Image();
+ img.onload = () => {
+ const context = canvas.getContext('2d');
+ context.fillStyle = 'red';
+ context.fillRect(0, 0, 100, 100);
+
+ context.strokeStyle = context.createPattern(img, 'repeat');
+ context.fillStyle = 'green';
+ context.filter = 'url(#disp)';
+ context.fillRect(0, 0, 100, 100);
+
+ takeScreenshot();
+ };
+ img.onerror = takeScreenshot;
+ img.src = 'https://{{hosts[alt][www]}}:{{ports[https][0]}}/images/grgr-256x256.png';
+</script>
Regression Test / PoC
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/reference/green-100x100.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/reference/green-100x100.html
new file mode 100644
index 0000000..a3ab3bb
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/reference/green-100x100.html
@@ -0,0 +1,2 @@
+<!doctype html>
+<div style="width: 100px; height: 100px; background-color: green"></div>
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-fillpaint.sub.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-fillpaint.sub.html
new file mode 100644
index 0000000..d614ef1
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-fillpaint.sub.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<title>SVG <feDisplacementMap> filter using a cross-origin FillPaint has no effect</title>
+<script src="/common/reftest-wait.js"></script>
+<link rel="match" href="reference/green-100x100.html">
+<canvas id="canvas" width="100" height="100">
+<svg width="0" height="0">
+ <filter id="disp">
+ <feDisplacementMap in="SourceGraphic" in2="FillPaint" scale="100"
+ yChannelSelector="R" xChannelSelector="G"/>
+ </filter>
+</svg>
+<script>
+ const img = new Image();
+ img.onload = () => {
+ const context = canvas.getContext('2d');
+ context.fillStyle = 'red';
+ context.fillRect(0, 0, 100, 100);
+
+ context.fillStyle = context.createPattern(img, 'repeat');
+ context.strokeStyle = 'green';
+ context.lineWidth = 50;
+ context.filter = 'url(#disp)';
+ context.strokeRect(25, 25, 50, 50);
+
+ takeScreenshot();
+ };
+ img.onerror = takeScreenshot;
+ img.src = 'https://{{hosts[alt][www]}}:{{ports[https][0]}}/images/grgr-256x256.png';
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-sourcealpha.sub.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-sourcealpha.sub.html
new file mode 100644
index 0000000..46957b3
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-sourcealpha.sub.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<title>SVG <feDisplacementMap> filter using a tainted SourceAlpha has no effect</title>
+<script src="/common/reftest-wait.js"></script>
+<link rel="match" href="reference/green-100x100.html">
+<canvas id="canvas" width="100" height="100">
+<svg width="0" height="0">
+ <filter id="disp">
+ <feDisplacementMap in="SourceGraphic" in2="SourceAlpha" scale="100"
+ yChannelSelector="A"/>
+ </filter>
+</svg>
+<script>
+ const img = new Image();
+ img.onload = () => {
+ const context = canvas.getContext('2d');
+ context.drawImage(img, 0, 0);
+ context.fillStyle = 'red';
+ context.fillRect(0, 0, 100, 100);
+
+ context.fillStyle = 'green';
+ context.filter = 'url(#disp)';
+ context.fillRect(0, 0, 100, 100);
+
+ takeScreenshot();
+ };
+ img.onerror = takeScreenshot;
+ img.src = 'https://{{hosts[alt][www]}}:{{ports[https][0]}}/images/grgr-256x256.png';
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-strokepaint.sub.html b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-strokepaint.sub.html
new file mode 100644
index 0000000..d2e7b0d4
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-strokepaint.sub.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html class="reftest-wait">
+<title>SVG <feDisplacementMap> filter using a cross-origin StrokePaint has no effect</title>
+<script src="/common/reftest-wait.js"></script>
+<link rel="match" href="reference/green-100x100.html">
+<canvas id="canvas" width="100" height="100">
+<svg width="0" height="0">
+ <filter id="disp">
+ <feDisplacementMap in="SourceGraphic" in2="StrokePaint" scale="100"
+ yChannelSelector="R" xChannelSelector="G"/>
+ </filter>
+</svg>
+<script>
+ const img = new Image();
+ img.onload = () => {
+ const context = canvas.getContext('2d');
+ context.fillStyle = 'red';
+ context.fillRect(0, 0, 100, 100);
+
+ context.strokeStyle = context.createPattern(img, 'repeat');
+ context.fillStyle = 'green';
+ context.filter = 'url(#disp)';
+ context.fillRect(0, 0, 100, 100);
+
+ takeScreenshot();
+ };
+ img.onerror = takeScreenshot;
+ img.src = 'https://{{hosts[alt][www]}}:{{ports[https][0]}}/images/grgr-256x256.png';
+</script>
Original Bug Report
FeDisplacementMap timing side-channel bypass via untainted builtin SVG filter inputs
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: Blink’s SVG filter implementation potentially fails to propagate origin-taint status to builtin filter inputs like FillPaint, StrokePaint, and SourceAlpha. This oversight allows an attacker to bypass security restrictions on the <feDisplacementMap> filter intended to prevent timing side-channel attacks. By using a tainted canvas pattern as FillPaint for a displacement map, an attacker could extract cross-origin pixel data by measuring rendering time variations.
Affected files:
third_party/blink/renderer/core/svg/graphics/filters/svg_filter_builder.ccthird_party/blink/renderer/platform/graphics/filters/fe_displacement_map.ccthird_party/blink/renderer/modules/canvas/canvas2d/canvas_rendering_context_2d_state.ccthird_party/blink/renderer/core/paint/filter_effect_builder.ccthird_party/blink/renderer/platform/graphics/filters/paint_filter_effect.ccthird_party/blink/renderer/platform/graphics/filters/source_alpha.cc
Estimated timestamp from git blame: 2020-02-18
Summary
A potential vulnerability in Blink’s SVG filter building logic allows an attacker to bypass the origin-taint check in the feDisplacementMap filter. This issue re-enables a timing side-channel attack where cross-origin pixel values can be extracted by observing variations in rendering performance, bypassing the defense originally implemented for crbug.com/778506.
Root Cause Analysis
The vulnerability stems from two related issues:
-
Untainted Builtin Filter Effects: In
SVGFilterBuilder::SVGFilterBuilder()(third_party/blink/renderer/core/svg/graphics/filters/svg_filter_builder.cc), the builtin effects forFillPaint,StrokePaint, andSourceAlphaare created without propagating any origin-taint status:builtin_effects_.insert(FilterInputKeywords::SourceAlpha(), MakeGarbageCollected<SourceAlpha>(source_graphic)); if (fill_flags) { builtin_effects_.insert(FilterInputKeywords::FillPaint(), MakeGarbageCollected<PaintFilterEffect>( source_graphic->GetFilter(), *fill_flags)); }The
FilterEffectbase class defaultsorigin_tainted_tofalse. While the canvas context may be tainted (e.g., by settingfillStyleto a cross-originCanvasPattern), andSourceGraphicis correctly marked as tainted, the newly createdPaintFilterEffectobjects forFillPaintandStrokePaintare never explicitly marked as tainted. Consequently, theirOriginTainted()methods incorrectly returnfalse. -
Incomplete Security Check in
FEDisplacementMap: The Filter Effects Module Level 1 specification requiresfeDisplacementMapto act as a pass-through filter if either the first or second input is origin-tainted to prevent timing attacks. However, inFEDisplacementMap::CreateImageFilter()(third_party/blink/renderer/platform/graphics/filters/fe_displacement_map.cc), the implementation only checks the second input:// FEDisplacementMap must be a pass-through filter if // the origin is tainted. See: // https://drafts.fxtf.org/filter-effects/#fedisplacemnentmap-restrictions. if (InputEffect(1)->OriginTainted()) return color;It misses the check for
InputEffect(0)->OriginTainted().
Exploitation Scenario
If an attacker sets canvas.fillStyle to a cross-origin CanvasPattern and applies an SVG filter containing an <feDisplacementMap> element with in2="FillPaint", the cross-origin shader is carried into the FillPaint effect. Because FillPaint is incorrectly considered untainted, the security check in FEDisplacementMap is bypassed. The filter proceeds to use cross-origin pixel values as displacement vectors.
An attacker can perform a pixel-by-pixel extraction of cross-origin image content by measuring the time it takes to render a canvas with the displacement filter applied. Rendering timing varies depending on the displacement distance (driven by the victim pixel values), allowing for an information leak of cross-origin data (CWE-200).
Potential Reproduction Steps
Note: These are suggested steps to trigger the vulnerability. Tooling does not currently allow running arbitrary code to confirm the exploit.
- Host an attacker-controlled page with an inline SVG filter:
<svg> <filter id="f"> <feDisplacementMap in="SourceGraphic" in2="FillPaint" xChannelSelector="R" yChannelSelector="G" scale="500"/> </filter> </svg> - Load a cross-origin image without CORS:
img.src = 'https://victim.example/secret.png'; - Upon image load, create a pattern and set it as the canvas
fillStyle(this taints the canvas context):const ctx = canvas.getContext('2d'); const p = ctx.createPattern(img, 'repeat'); ctx.fillStyle = p; ctx.filter = 'url(#f)'; - Repeatedly call
ctx.fillRect(0,0,1,1)while varyingSourceGraphicprobe patterns and measuring execution time usingperformance.now(). - The render timing will depend on the R/G channel values of the cross-origin image at the displacement point. An attacker can binary-search for specific pixel values based on these timing variations.
Suggested Fix
- Propagate Taint Status to Builtin Effects: Update the
SVGFilterBuilderconstructor to mark theSourceAlpha,FillPaint, andStrokePainteffects as tainted if the incomingsource_graphicis tainted or if the context flags contain cross-origin data. Alternatively, ensure thePaintFilterEffectitself evaluates taint based on the provided flags. - Enforce Complete Security Check: Correct the implementation in
FEDisplacementMap::CreateImageFilter()to check both inputs as required by the specification:if (InputEffect(0)->OriginTainted() || InputEffect(1)->OriginTainted()) return color;
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.