Chrome · Canvas
CVE-2026-13821
UAF in Canvas
Overview
High
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/html/canvas/canvas_rendering_context.cc |
modified | |
GetChildPaintRecordthird_party/blink/renderer/core/html/canvas/canvas_rendering_context.cc |
modified | |
ifthird_party/blink/renderer/core/html/canvas/html_canvas_element.cc |
modified |
Files Changed
third_party/blink/renderer/core/html/canvas/canvas_rendering_context.ccthird_party/blink/renderer/core/html/canvas/canvas_rendering_context.hthird_party/blink/renderer/core/html/canvas/html_canvas_element.ccthird_party/blink/renderer/core/html/canvas/html_canvas_element.h
Patch
From dbe9c63eb518f68fe93d9fc839ce3d973a0aebce Mon Sep 17 00:00:00 2001
From: Philip Rogers <pdr@chromium.org>
Date: Thu, 14 May 2026 19:43:34 -0700
Subject: [PATCH] [html-in-canvas] Prevent drawing elements into different canvases
For pixel-perfect rendering, there is a connection between an
ElementImage and the canvas it comes from. This patch throws an
exception when drawing an ElementImage into a canvas it did not come
from. For consistency, Elements are also restricted to only being
drawn into the canvas they come from.
This has a side benefit for performance/security as well. When a
snapshot, stored inside an ElementImage, can be drawn on multiple
threads, there is a risk of race conditions (think: fonts, videos,
etc). Copies are always an option, but that comes with performance
implications. Note that canvases cannot be transferred offscreen unless
the context is unset[1] which means that there is no risk of drawing an
ElementImage onto a canvas and later moving the canvas to a different
thread.
[1] https://html.spec.whatwg.org/multipage/canvas.html#dom-canvas-transfercontroltooffscreen-dev
Fixed: 500074335, 513142445
Change-Id: Iec0eda1b883180e920891e7ee7fb54a748503cc3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7797621
Commit-Queue: Stefan Zager <szager@chromium.org>
Reviewed-by: Stefan Zager <szager@chromium.org>
Commit-Queue: Philip Rogers <pdr@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1631016}
---
diff --git a/third_party/blink/renderer/core/html/canvas/canvas_rendering_context.cc b/third_party/blink/renderer/core/html/canvas/canvas_rendering_context.cc
index 12e15c4..29f1106 100644
--- a/third_party/blink/renderer/core/html/canvas/canvas_rendering_context.cc
+++ b/third_party/blink/renderer/core/html/canvas/canvas_rendering_context.cc
@@ -143,6 +143,43 @@
exception_state);
}
+bool CanvasRenderingContext::IsDrawElementImageEligible(
+ const V8UnionElementOrElementImage* element_or_image,
+ const String& func_name,
+ ExceptionState& exception_state) {
+ if (element_or_image->IsElement()) {
+ return IsDrawElementImageEligible(element_or_image->GetAsElement(),
+ func_name, exception_state);
+ }
+
+ const auto& record = element_or_image->GetAsElementImage()->PaintRecord();
+ if (!record) {
+ exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
+ "The ElementImage has been closed.");
+ return false;
+ }
+
+ DOMNodeId current_canvas_node_id = kInvalidDOMNodeId;
+ if (Host()) {
+ if (!Host()->IsOffscreenCanvas()) {
+ current_canvas_node_id =
+ static_cast<HTMLCanvasElement*>(Host())->GetDomNodeId();
+ } else {
+ current_canvas_node_id =
+ static_cast<OffscreenCanvas*>(Host())->PlaceholderCanvasId();
+ }
+ }
+
+ if (current_canvas_node_id == kInvalidDOMNodeId ||
+ record->paint_state.canvas_node_id != current_canvas_node_id) {
+ exception_state.ThrowDOMException(
+ DOMExceptionCode::kInvalidStateError,
+ "The source was captured from a different canvas.");
+ return false;
+ }
+ return true;
+}
+
std::optional<CanvasChildPaintRecord>
CanvasRenderingContext::GetChildPaintRecord(Element* element) {
return Host()->GetCanvasChildPaintRecord(element->GetDomNodeId());
@@ -159,13 +196,13 @@
gpu::SharedImageUsageSet usage,
const String& func_name,
ExceptionState& exception_state) {
+ if (!IsDrawElementImageEligible(element, func_name, exception_state)) {
+ return nullptr;
+ }
+
std::optional<CanvasChildPaintRecord> child_paint_record;
if (element->IsElement()) {
- Element* dom_element = element->GetAsElement();
- if (!IsDrawElementImageEligible(dom_element, func_name, exception_state)) {
- return nullptr;
- }
- child_paint_record = GetChildPaintRecord(dom_element);
+ child_paint_record = GetChildPaintRecord(element->GetAsElement());
} else {
if (const auto& record = element->GetAsElementImage()->PaintRecord()) {
child_paint_record = *record;
diff --git a/third_party/blink/renderer/core/html/canvas/canvas_rendering_context.h b/third_party/blink/renderer/core/html/canvas/canvas_rendering_context.h
index d7557e6a..5097560 100644
--- a/third_party/blink/renderer/core/html/canvas/canvas_rendering_context.h
+++ b/third_party/blink/renderer/core/html/canvas/canvas_rendering_context.h
@@ -344,6 +344,10 @@
const String& func_name,
ExceptionState& exception_state);
+ bool IsDrawElementImageEligible(const V8UnionElementOrElementImage* element,
+ const String& func_name,
+ ExceptionState& exception_state);
+
std::optional<CanvasChildPaintRecord> GetChildPaintRecord(Element* element);
std::optional<cc::PaintRecord> empty_recording_;
diff --git a/third_party/blink/renderer/core/html/canvas/html_canvas_element.cc b/third_party/blink/renderer/core/html/canvas/html_canvas_element.cc
index 65781173..2a199c1 100644
--- a/third_party/blink/renderer/core/html/canvas/html_canvas_element.cc
+++ b/third_party/blink/renderer/core/html/canvas/html_canvas_element.cc
@@ -926,18 +926,9 @@
const V8UnionElementOrElementImage* element_or_image,
DOMMatrix* draw_transform,
ExceptionState& exception_state) {
- if (element_or_image->IsElement()) {
- if (!VerifyDrawElementImageEligibility(element_or_image->GetAsElement(),
- "getElementTransform",
- exception_state)) {
- return nullptr;
- }
- } else if (element_or_image->IsElementImage()) {
- if (!element_or_image->GetAsElementImage()->PaintRecord()) {
- exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
- "The ElementImage has been closed.");
- return nullptr;
- }
+ if (!VerifyDrawElementImageEligibility(
+ element_or_image, "getElementTransform", exception_state)) {
+ return nullptr;
}
const auto* paint_state = GetCanvasChildPaintState(element_or_image);
@@ -962,9 +953,10 @@
return false;
}
if (element->parentElement() != this) {
- exception_state.ThrowTypeError(
+ exception_state.ThrowDOMException(
+ DOMExceptionCode::kInvalidStateError,
"Only immediate children of the <canvas> element can be passed to " +
- func_name + ".");
+ func_name + ".");
return false;
}
if (!layoutSubtree()) {
@@ -977,6 +969,33 @@
return true;
}
+bool HTMLCanvasElement::VerifyDrawElementImageEligibility(
+ const V8UnionElementOrElementImage* element_or_image,
+ const String& func_name,
+ ExceptionState& exception_state) const {
+ if (element_or_image->IsElement()) {
+ return VerifyDrawElementImageEligibility(element_or_image->GetAsElement(),
+ func_name, exception_state);
+ }
+
+ const auto& record = element_or_image->GetAsElementImage()->PaintRecord();
+ if (!record) {
+ exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
+ "The ElementImage has been closed.");
+ return false;
+ }
+
+ if (record->paint_state.canvas_node_id == kInvalidDOMNodeId ||
+ record->paint_state.canvas_node_id !=
+ const_cast<HTMLCanvasElement*>(this)->GetDomNodeId()) {
+ exception_state.ThrowDOMException(
+ DOMExceptionCode::kInvalidStateError,
+ "The source was captured from a different canvas.");
+ return false;
+ }
+ return true;
+}
+
ElementImage* HTMLCanvasElement::captureElementImage(
Element* element,
ExceptionState& exception_state) {
diff --git a/third_party/blink/renderer/core/html/canvas/html_canvas_element.h b/third_party/blink/renderer/core/html/canvas/html_canvas_element.h
index 3af1275..2b023e1a 100644
--- a/third_party/blink/renderer/core/html/canvas/html_canvas_element.h
+++ b/third_party/blink/renderer/core/html/canvas/html_canvas_element.h
@@ -355,6 +355,11 @@
const String& func_name,
ExceptionState& exception_state) const;
+ bool VerifyDrawElementImageEligibility(
+ const V8UnionElementOrElementImage* element,
+ const String& func_name,
+ ExceptionState& exception_state) const;
+
ElementImage* captureElementImage(Element* element, ExceptionState&);
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/draw-element-different-canvas.html b/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/draw-element-different-canvas.html
new file mode 100644
index 0000000..90edf309
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/draw-element-different-canvas.html
@@ -0,0 +1,46 @@
+<!DOCTYPE HTML>
+<title>Prevent drawing Elements into different canvases</title>
+<script src='/resources/testharness.js'></script>
+<script src='/resources/testharnessreport.js'></script>
+<script src="/wpt_internal/resources/canvas-draw-element/waitForCanvasPaint.js"></script>
+<style>
+ #child1, #child2 {
+ width: 100px;
+ height: 100px;
+ background: green;
+ }
+</style>
+<canvas id="canvas1" width="200" height="200" layoutsubtree>
+ <div id="child1"></div>
+</canvas>
+<canvas id="canvas2" width="200" height="200" layoutsubtree>
+ <div id="child2"></div>
+</canvas>
+
+<script>
+ 'use strict';
+
+ promise_test(async t => {
+ await waitForCanvasPaint(canvas1);
+
+ const ctx2 = canvas2.getContext('2d');
+
+ assert_throws_dom('InvalidStateError', () => {
+ ctx2.drawElementImage(child1, 0, 0);
+ }, 'Drawing an Element from a different canvas must throw InvalidStateError');
+
+ assert_throws_dom('InvalidStateError', () => {
+ canvas2.getElementTransform(child1, new DOMMatrix());
+ }, 'Getting transform for an Element from a different canvas must throw InvalidStateError');
+
+ assert_throws_dom('InvalidStateError', () => {
+ let standaloneCtx = new OffscreenCanvas(200, 200).getContext('2d');
+ standaloneCtx.drawElementImage(child1, 0, 0);
+ }, 'Drawing an Element into a standalone OffscreenCanvas must throw InvalidStateError');
+
+ assert_throws_dom('InvalidStateError', () => {
+ let standalone = new OffscreenCanvas(200, 200);
+ standalone.getElementTransform(child1, new DOMMatrix());
+ }, 'Getting transform for an Element from a standalone OffscreenCanvas must throw InvalidStateError');
+ }, 'Prevent drawing Element into a different canvas');
+</script>
diff --git a/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/draw-element-image-different-canvas.html b/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/draw-element-image-different-canvas.html
new file mode 100644
index 0000000..e33c1ab
--- /dev/null
+++ b/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/draw-element-image-different-canvas.html
@@ -0,0 +1,124 @@
+<!DOCTYPE HTML>
+<title>Prevent drawing ElementImages into different canvases</title>
+<script src='/resources/testharness.js'></script>
+<script src='/resources/testharnessreport.js'></script>
+<script src="/wpt_internal/resources/canvas-draw-element/waitForCanvasPaint.js"></script>
+<style>
+ #child1, #child2 {
+ width: 100px;
+ height: 100px;
+ background: green;
+ }
+</style>
+<canvas id="canvas1" width="200" height="200" layoutsubtree>
+ <div id="child1"></div>
+</canvas>
+<canvas id="canvas2" width="200" height="200" layoutsubtree>
+ <div id="child2"></div>
+</canvas>
+<canvas id="canvas3" width="200" height="200" layoutsubtree></canvas>
+
+<script>
+ 'use strict';
+
+ promise_test(async t => {
+ await waitForCanvasPaint(canvas1);
+
+ const elementImage1 = canvas1.captureElementImage(child1);
+ const ctx2 = canvas2.getContext('2d');
+
+ assert_throws_dom('InvalidStateError', () => {
+ ctx2.drawElementImage(elementImage1, 0, 0);
+ }, 'Drawing an ElementImage into a different canvas must throw InvalidStateError');
+
+ assert_throws_dom('InvalidStateError', () => {
+ canvas2.getElementTransform(elementImage1, new DOMMatrix());
+ }, 'Getting transform for an ElementImage from a different canvas must throw InvalidStateError');
+
+ assert_throws_dom('InvalidStateError', () => {
+ let standaloneCtx = new OffscreenCanvas(200, 200).getContext('2d');
+ standaloneCtx.drawElementImage(elementImage1, 0, 0);
+ }, 'Drawing an ElementImage into a standalone OffscreenCanvas must throw InvalidStateError');
+
+ assert_throws_dom('InvalidStateError', () => {
+ let standalone = new OffscreenCanvas(200, 200);
+ standalone.getElementTransform(elementImage1, new DOMMatrix());
+ }, 'Getting transform for an ElementImage from a standalone OffscreenCanvas must throw InvalidStateError');
+ }, 'Prevent drawing ElementImage into a different canvas on main thread');
+
+ const workerCode = `
+ let canvas2, ctx1, ctx2;
+ self.onmessage = function(e) {
+ if (e.data.canvas1) {
+ ctx1 = e.data.canvas1.getContext('2d');
+ }
+ if (e.data.canvas2) {
+ canvas2 = e.data.canvas2;
+ ctx2 = canvas2.getContext('2d');
+ }
+ if (e.data.elementImage1) {
+ let results = {};
+
+ try {
+ ctx2.drawElementImage(e.data.elementImage1, 0, 0);
+ results.drawError = 'success';
+ } catch (e) {
+ results.drawError = e.name;
+ }
+
+ try {
+ canvas2.getElementTransform(e.data.elementImage1, new DOMMatrix());
+ results.transformError = 'success';
+ } catch (e) {
+ results.transformError = e.name;
+ }
+
+ try {
+ let standaloneCtx = new OffscreenCanvas(200, 200).getContext('2d');
+ standaloneCtx.drawElementImage(e.data.elementImage1, 0, 0);
+ results.standaloneDrawError = 'success';
+ } catch (e) {
+ results.standaloneDrawError = e.name;
+ }
+
+ try {
+ let standalone = new OffscreenCanvas(200, 200);
+ standalone.getElementTransform(e.data.elementImage1, new DOMMatrix());
+ results.standaloneTransformError = 'success';
+ } catch (e) {
+ results.standaloneTransformError = e.name;
+ }
+
+ self.postMessage(results);
+ }
+ };
+ `;
+
+ promise_test(async t => {
+ await waitForCanvasPaint(canvas1);
+
+ const blob = new Blob([workerCode], { type: 'application/javascript' });
+ const worker = new Worker(URL.createObjectURL(blob));
+
+ const offscreen1 = canvas1.transferControlToOffscreen();
+ const offscreen2 = canvas3.transferControlToOffscreen();
+
+ worker.postMessage({ canvas1: offscreen1 }, [offscreen1]);
+ worker.postMessage({ canvas2: offscreen2 }, [offscreen2]);
+
+ const response_promise = new Promise(resolve => {
+ worker.onmessage = t.step_func(msg => {
+ resolve(msg.data);
+ });
+ });
+
+ const elementImage1 = canvas1.captureElementImage(child1);
+ worker.postMessage({ elementImage1: elementImage1 }, [elementImage1]);
+
+ const result = await response_promise;
+ assert_equals(result.drawError, 'InvalidStateError', 'Drawing an ElementImage into a different canvas must throw InvalidStateError');
+ assert_equals(result.transformError, 'InvalidStateError', 'Getting the transform for an ElementImage from a different canvas must throw InvalidStateError');
+ assert_equals(result.standaloneDrawError, 'InvalidStateError', 'Drawing an ElementImage into a standalone OffscreenCanvas must throw InvalidStateError');
+ assert_equals(result.standaloneTransformError, 'InvalidStateError', 'Getting the transform for an ElementImage from a standalone OffscreenCanvas must throw InvalidStateError');
+ }, 'Prevent drawing ElementImage into a different canvas in a worker thread');
+</script>
diff --git a/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/error-conditions.html b/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/error-conditions.html
index 6e34f1a..4ecabec 100644
--- a/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/error-conditions.html
+++ b/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/error-conditions.html
@@ -32,7 +32,7 @@
promise_test(async () => {
await waitForCanvasPaint(canvas);
- assert_throws_js(TypeError,
+ assert_throws_dom("InvalidStateError",
() => canvas.getContext("2d").drawElementImage(grandchild, 20, 30),
"Can't draw non-direct children.");
@@ -67,10 +67,10 @@
"Can't get element transform if layoutsubtree is not specified.");
// Also test wrong children exceptions
- assert_throws_js(TypeError,
+ assert_throws_dom("InvalidStateError",
() => canvas.captureElementImage(grandchild),
"Can't capture non-direct children.");
- assert_throws_js(TypeError,
+ assert_throws_dom("InvalidStateError",
() => canvas.getElementTransform(grandchild, new DOMMatrix()),
"Can't get transform for non-direct children.");
});
diff --git a/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/offscreenCanvas-drawElementImage.html b/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/offscreenCanvas-drawElementImage.html
index 4bb42c2..52802e1 100644
--- a/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/offscreenCanvas-drawElementImage.html
+++ b/third_party/blink/web_tests/wpt_internal/html/canvas/drawElementImage/offscreenCanvas-drawElementImage.html
@@ -24,7 +24,7 @@
assert_equals(elementImage.width, 100);
assert_equals(elementImage.height, 100);
- const offscreen = new OffscreenCanvas(200, 200);
+ const offscreen = canvas.transferControlToOffscreen();
const offscreenContext = offscreen.getContext('2d');
let draw_transform = offscreenContext.drawElementImage(elementImage, 0, 0);
assert_array_equals(
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page