Released November 3, 2025

CVE-2025-43392

CVSS 4.3 severity medium CWE-942 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N

Overview

Impact:

Description: A website may exfiltrate image data cross-origin

Researchers: Tom Van Goethem

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.

Patch Details

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>
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; }
 

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2025-43392
Bugzilla 297566
Component WebCore
Bug Class CrossOrigin
Severity medium
Commit 569f9f07502847a5…
Advisory Apple Advisory

Terminology

  • 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.

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.

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.

Changed Functions

Function File Change Note
OffscreenCanvas::commitToPlaceholderCanvas Source/WebCore/html/OffscreenCanvas.cpp modified Passes m_context->canvasBase().originClean() into setPlaceholderBuffer so the taint travels with the bitmap.
PlaceholderRenderingContextSource::setPlaceholderBuffer Source/WebCore/html/canvas/PlaceholderRenderingContext.cpp modified Gains an originClean parameter and captures/forwards it through the callOnMainThread hop to the placeholder.
PlaceholderRenderingContext::setPlaceholderBuffer Source/WebCore/html/canvas/PlaceholderRenderingContext.cpp modified Applies the flag to the destination: canvasBase().setOriginClean() or setOriginTainted().
PlaceholderRenderingContext(.h) setPlaceholderBuffer decls Source/WebCore/html/canvas/PlaceholderRenderingContext.h modified Signatures updated to carry originClean end-to-end.

Files Changed

Source Files

  • Source/WebCore/html/OffscreenCanvas.cpp
  • Source/WebCore/html/canvas/PlaceholderRenderingContext.cpp
  • Source/WebCore/html/canvas/PlaceholderRenderingContext.h

Test Files

  • LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image-expected.txt
  • LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image.html

Regression Test

The fix ships this proof-of-concept / regression test:

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>