Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in SVG
DescriptionObservable discrepancy in SVG
ComponentSVG
Bug ClassLogic Error
Tracker497854976
Fix commit0230e12ab236 (chromium/src) +98/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
for
third_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.cc
  • third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/reference/green-100x100.html
  • third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-fillpaint.sub.html
  • third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-sourcealpha.sub.html
  • third_party/blink/web_tests/external/wpt/html/canvas/element/manual/filters/svg-displacement-filter-tainted-strokepaint.sub.html
From 0230e12ab23670f4004ee67f48896e8a68a7131e Mon Sep 17 00:00:00 2001
From: Fredrik Söderquist <[email protected]>
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 <[email protected]>
Commit-Queue: Fredrik Söderquist <[email protected]>
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 &lt;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 &lt;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 &lt;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>
Loading diff…

Regression Test / PoC

shipped with the fix
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 &lt;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 &lt;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 &lt;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>
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • third_party/blink/renderer/platform/graphics/filters/fe_displacement_map.cc
  • third_party/blink/renderer/modules/canvas/canvas2d/canvas_rendering_context_2d_state.cc
  • third_party/blink/renderer/core/paint/filter_effect_builder.cc
  • third_party/blink/renderer/platform/graphics/filters/paint_filter_effect.cc
  • third_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:

  1. Untainted Builtin Filter Effects: In SVGFilterBuilder::SVGFilterBuilder() (third_party/blink/renderer/core/svg/graphics/filters/svg_filter_builder.cc), the builtin effects for FillPaint, StrokePaint, and SourceAlpha are 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 FilterEffect base class defaults origin_tainted_ to false. While the canvas context may be tainted (e.g., by setting fillStyle to a cross-origin CanvasPattern), and SourceGraphic is correctly marked as tainted, the newly created PaintFilterEffect objects for FillPaint and StrokePaint are never explicitly marked as tainted. Consequently, their OriginTainted() methods incorrectly return false.

  2. Incomplete Security Check in FEDisplacementMap: The Filter Effects Module Level 1 specification requires feDisplacementMap to act as a pass-through filter if either the first or second input is origin-tainted to prevent timing attacks. However, in FEDisplacementMap::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.

  1. 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>
    
  2. Load a cross-origin image without CORS: img.src = 'https://victim.example/secret.png';
  3. 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)';
    
  4. Repeatedly call ctx.fillRect(0,0,1,1) while varying SourceGraphic probe patterns and measuring execution time using performance.now().
  5. 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

  1. Propagate Taint Status to Builtin Effects: Update the SVGFilterBuilder constructor to mark the SourceAlpha, FillPaint, and StrokePaint effects as tainted if the incoming source_graphic is tainted or if the context flags contain cross-origin data. Alternatively, ensure the PaintFilterEffect itself evaluates taint based on the provided flags.
  2. 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.

View on issue tracker