CVE-2025-43392
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.cppSource/WebCore/html/canvas/PlaceholderRenderingContext.cppSource/WebCore/html/canvas/PlaceholderRenderingContext.h
Test Files
LayoutTests/http/tests/security/offscreen-canvas-remote-read-remote-image-expected.txtLayoutTests/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>