Medium CVSS 4.3 webkit Cross Origin 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionA website may exfiltrate image data cross-origin
ComponentWebCore
Sub-componentWebCore HTML
Bug ClassCross Origin
Tracker297566
Fix commit569f9f075028 (WebKit/WebKit) +72/-8
CWECWE-942
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedTom Van Goethem
Disclosed2025-11-03

Background

Origin-clean flag / canvas taint
A canvas becomes tainted (origin-clean=false) when it draws cross-origin pixels, which blocks pixel read-back (toDataURL/getImageData).
OffscreenCanvas placeholder
An OffscreenCanvas transferred from a canvas commits its rendered frames back to a visible placeholder HTMLCanvasElement.
setPlaceholderBuffer pipeline
The path (source -> main thread -> placeholder) that hands the rendered ImageBuffer to the visible canvas.
Same-origin policy for canvas
Reading pixels from a tainted canvas is forbidden to prevent cross-origin image exfiltration.

Root Cause Analysis

An OffscreenCanvas can draw a cross-origin image (which taints the canvas, clearing its origin-clean flag) and then transfer its rendered bitmap to a visible placeholder HTMLCanvasElement via commitToPlaceholderCanvas / PlaceholderRenderingContextSource::setPlaceholderBuffer.

Pre-patch, that transfer carried the bitmap and its opaque flag but NOT the origin-clean/tainted status: setPlaceholderBuffer took only (ImageBuffer&, bool opaque), so the output placeholder canvas was left origin-clean even though the pixels came from a tainted OffscreenCanvas. With the destination canvas wrongly considered origin-clean, script could call toDataURL()/getImageData()/toBlob() on it and read back the cross-origin image pixels – a same-origin-policy bypass that exfiltrates cross-origin image data.

The fix threads an originClean boolean through the whole placeholder pipeline (OffscreenCanvas::commitToPlaceholderCanvas passes m_context->canvasBase().originClean(); PlaceholderRenderingContextSource::setPlaceholderBuffer and PlaceholderRenderingContext::setPlaceholderBuffer take and forward it), and the output side applies it: if (originClean) canvasBase().setOriginClean(); else canvasBase().setOriginTainted();.

The restored invariant is that the origin-clean taint of the OffscreenCanvas is preserved when its bitmap is committed to the placeholder canvas, so a tainted source keeps the destination tainted and read-back is blocked.

Key insight
Committing an OffscreenCanvas bitmap to its placeholder canvas failed to carry the origin-clean/tainted flag, so a canvas holding cross-origin pixels was treated as readable; propagating and applying the taint blocks the cross-origin read-back.

Attack Path

  1. Taint an OffscreenCanvas Draw a cross-origin image (no CORS) into an OffscreenCanvas, clearing its origin-clean flag (canvas becomes tainted).
  2. Commit to a placeholder canvas Transfer control / commit the OffscreenCanvas bitmap to a visible HTMLCanvasElement via the placeholder rendering context.
  3. Lose the taint Pre-patch, setPlaceholderBuffer did not carry originClean, so the destination canvas is treated as origin-clean despite holding cross-origin pixels.
  4. Read back cross-origin pixels Call toDataURL()/getImageData() on the destination canvas to exfiltrate the cross-origin image data – an SOP bypass.

Impact Assessment

A same-origin-policy bypass: the OffscreenCanvas taint was dropped when committing to the placeholder canvas, letting script read back cross-origin image pixels (toDataURL/getImageData). It is an information-disclosure primitive with no memory-safety component, in the WebContent process. Rated medium.

Files Changed

  • LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image-expected.txt
  • LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image.html
  • Source/WebCore/html/OffscreenCanvas.cpp
  • Source/WebCore/html/canvas/PlaceholderRenderingContext.cpp
  • Source/WebCore/html/canvas/PlaceholderRenderingContext.h

Audit Directions

  • Other bitmap transfers
    Audit canvas/ImageBitmap transfer paths (transferToImageBitmap, drawImage from canvas, VideoFrame) to ensure origin-clean/taint travels with pixel data.
  • Placeholder pipeline flags
    Review what metadata setPlaceholderBuffer carries (opaque, originClean) for any other security-relevant flag dropped across the main-thread hop.
  • setOriginClean/Tainted callers
    grep for setOriginClean()/setOriginTainted() to confirm every path that copies external pixels into a canvas sets taint correctly.
diff --git a/Source/WebCore/html/OffscreenCanvas.cpp b/Source/WebCore/html/OffscreenCanvas.cpp
index ddc5b6060732..7788ce0845fa 100644
--- a/Source/WebCore/html/OffscreenCanvas.cpp
+++ b/Source/WebCore/html/OffscreenCanvas.cpp
@@ -421,8 +421,8 @@ void OffscreenCanvas::commitToPlaceholderCanvas()
     RefPtr imageBuffer = m_context->surfaceBufferToImageBuffer(CanvasRenderingContext::SurfaceBuffer::DisplayBuffer);
     if (!imageBuffer)
         return;
-    m_placeholderSource->setPlaceholderBuffer(*imageBuffer, m_context->isOpaque());
-    }
+    m_placeholderSource->setPlaceholderBuffer(*imageBuffer, m_context->canvasBase().originClean(), m_context->isOpaque());
+}
 
 void OffscreenCanvas::scheduleCommitToPlaceholderCanvas()
 {
diff --git a/Source/WebCore/html/canvas/PlaceholderRenderingContext.cpp b/Source/WebCore/html/canvas/PlaceholderRenderingContext.cpp
index 2c1c1b40a0e9..030ad8dd8e83 100644
--- a/Source/WebCore/html/canvas/PlaceholderRenderingContext.cpp
+++ b/Source/WebCore/html/canvas/PlaceholderRenderingContext.cpp
@@ -50,7 +50,7 @@ PlaceholderRenderingContextSource::PlaceholderRenderingContextSource(Placeholder
 {
 }
 
-void PlaceholderRenderingContextSource::setPlaceholderBuffer(ImageBuffer& imageBuffer, bool opaque)
+void PlaceholderRenderingContextSource::setPlaceholderBuffer(ImageBuffer& imageBuffer, bool originClean, bool opaque)
 {
     auto bufferVersion = ++m_bufferVersion;
     {
@@ -67,7 +67,7 @@ void PlaceholderRenderingContextSource::setPlaceholderBuffer(ImageBuffer& imageB
     std::unique_ptr serializedClone = ImageBuffer::sinkIntoSerializedImageBuffer(WTFMove(clone));
     if (!serializedClone)
         return;
-    callOnMainThread([weakPlaceholder = m_placeholder, buffer = WTFMove(serializedClone), bufferVersion, opaque] () mutable {
+    callOnMainThread([weakPlaceholder = m_placeholder, buffer = WTFMove(serializedClone), bufferVersion, originClean, opaque] () mutable {
         assertIsMainThread();
         RefPtr placeholder = weakPlaceholder.get();
         if (!placeholder)
@@ -87,7 +87,7 @@ void PlaceholderRenderingContextSource::setPlaceholderBuffer(ImageBuffer& imageB
             }
         }
 
-        placeholder->setPlaceholderBuffer(imageBuffer.releaseNonNull(), opaque);
+        placeholder->setPlaceholderBuffer(imageBuffer.releaseNonNull(), originClean, opaque);
         source->m_placeholderBufferVersion = bufferVersion;
     });
 }
@@ -136,9 +136,15 @@ void PlaceholderRenderingContext::setContentsToLayer(GraphicsLayer& layer)
     m_source->setContentsToLayer(layer, buffer.get(), m_opaque);
 }
 
-void PlaceholderRenderingContext::setPlaceholderBuffer(Ref<ImageBuffer>&& buffer, bool opaque)
+void PlaceholderRenderingContext::setPlaceholderBuffer(Ref<ImageBuffer>&& buffer, bool originClean, bool opaque)
 {
     m_opaque = opaque;
+
+    // Transfer the drawn bitmap and its origin-clean flag to the output canvas.
+    if (originClean)
+        canvasBase().setOriginClean();
+    else
+        canvasBase().setOriginTainted();
     canvasBase().setImageBufferAndMarkDirty(WTFMove(buffer));
 }
 
diff --git a/Source/WebCore/html/canvas/PlaceholderRenderingContext.h b/Source/WebCore/html/canvas/PlaceholderRenderingContext.h
index 10cf7779311a..730ebd43fb05 100644
--- a/Source/WebCore/html/canvas/PlaceholderRenderingContext.h
+++ b/Source/WebCore/html/canvas/PlaceholderRenderingContext.h
@@ -45,7 +45,7 @@ public:
     virtual ~PlaceholderRenderingContextSource() = default;
 
     // Called by the offscreen context to submit the frame.
-    void setPlaceholderBuffer(ImageBuffer&, bool opaque);
+    void setPlaceholderBuffer(ImageBuffer&, bool originClean, bool opaque);
 
     // Called by the placeholder context to attach to compositor layer.
     void setContentsToLayer(GraphicsLayer&, ImageBuffer*, bool opaque);
@@ -69,7 +69,7 @@ public:
     HTMLCanvasElement& canvas() const;
     Ref<HTMLCanvasElement> protectedCanvas() const { return canvas(); }
     IntSize size() const;
-    void setPlaceholderBuffer(Ref<ImageBuffer>&&, bool opaque);
+    void setPlaceholderBuffer(Ref<ImageBuffer>&&, bool originClean, bool opaque);
 
     PlaceholderRenderingContextSource& source() const { return m_source; }
 
diff --git a/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image-expected.txt b/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image-expected.txt
new file mode 100644
index 000000000000..c4752ac3ae39
--- /dev/null
+++ b/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image-expected.txt
@@ -0,0 +1,11 @@
+Test that offscreen canvas does not allow leaking cross-site image.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS Calling toDataURL() on an untainted canvas was allowed.
+PASS Calling toDataURL() on remote image was not allowed - Threw error: (SecurityError: The operation is insecure.).
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image.html b/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image.html
new file mode 100644
index 000000000000..a272ee97eb19
--- /dev/null
+++ b/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image.html
@@ -0,0 +1,47 @@
+<script src="/js-test-resources/js-test.js"></script>
+<body>
+    <canvas width="200" height="200"></canvas>
+    <script>
+        function testToDataURL(canvas, state, shouldSucceed)
+        {
+            state = "Calling toDataURL() on " + state;
+            try {
+                var dataURL = canvas.toDataURL();
+                debug((shouldSucceed ? "PASS " : "FAIL ") + state + " was allowed.");
+            } catch (e) {
+                debug((shouldSucceed ? "FAIL " : "PASS ") + state + " was not allowed - Threw error: (" + e + ").");
+            }
+        }
+
+        (async () => {
+            description("Test that offscreen canvas does not allow leaking cross-site image.");
+
+            window.jsTestIsAsync = true;
+
+            const image = new Image;
+            image.src = "http://localhost:8000/security/resources/abe.png";
+            await image.decode();
+
+            const canvas = document.querySelector('canvas');
+
+            testToDataURL(canvas, "an untainted canvas", true);
+
+            const offscreenCanvas = canvas.transferControlToOffscreen();
+            const offscreenContext = offscreenCanvas.getContext('bitmaprenderer');
+            const imageBitmapLeak = await createImageBitmap(image);
+
+            offscreenContext.transferFromImageBitmap(imageBitmapLeak);
+
+            await new Promise((resolve) => {
+                requestAnimationFrame(() => {
+                    requestAnimationFrame(() => {
+                        resolve();
+                    });
+                });
+            });
+
+            testToDataURL(canvas, "remote image", false);
+            finishJSTest();
+        })();
+    </script>
+</body>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image-expected.txt b/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image-expected.txt
new file mode 100644
index 000000000000..c4752ac3ae39
--- /dev/null
+++ b/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image-expected.txt
@@ -0,0 +1,11 @@
+Test that offscreen canvas does not allow leaking cross-site image.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS Calling toDataURL() on an untainted canvas was allowed.
+PASS Calling toDataURL() on remote image was not allowed - Threw error: (SecurityError: The operation is insecure.).
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image.html b/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image.html
new file mode 100644
index 000000000000..a272ee97eb19
--- /dev/null
+++ b/LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image.html
@@ -0,0 +1,47 @@
+<script src="/js-test-resources/js-test.js"></script>
+<body>
+    <canvas width="200" height="200"></canvas>
+    <script>
+        function testToDataURL(canvas, state, shouldSucceed)
+        {
+            state = "Calling toDataURL() on " + state;
+            try {
+                var dataURL = canvas.toDataURL();
+                debug((shouldSucceed ? "PASS " : "FAIL ") + state + " was allowed.");
+            } catch (e) {
+                debug((shouldSucceed ? "FAIL " : "PASS ") + state + " was not allowed - Threw error: (" + e + ").");
+            }
+        }
+
+        (async () => {
+            description("Test that offscreen canvas does not allow leaking cross-site image.");
+
+            window.jsTestIsAsync = true;
+
+            const image = new Image;
+            image.src = "http://localhost:8000/security/resources/abe.png";
+            await image.decode();
+
+            const canvas = document.querySelector('canvas');
+
+            testToDataURL(canvas, "an untainted canvas", true);
+
+            const offscreenCanvas = canvas.transferControlToOffscreen();
+            const offscreenContext = offscreenCanvas.getContext('bitmaprenderer');
+            const imageBitmapLeak = await createImageBitmap(image);
+
+            offscreenContext.transferFromImageBitmap(imageBitmapLeak);
+
+            await new Promise((resolve) => {
+                requestAnimationFrame(() => {
+                    requestAnimationFrame(() => {
+                        resolve();
+                    });
+                });
+            });
+
+            testToDataURL(canvas, "remote image", false);
+            finishJSTest();
+        })();
+    </script>
+</body>
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.