CVE-2026-7977
Overview
Background
- `OffscreenCanvas`
- Blink’s canvas object usable off the main thread, which tracks cross-origin taint via the
origin_clean_flag. - `origin_clean_`
- the per-canvas flag that, when
false, marks the canvas as tainted by cross-origin content and blocks pixel readback (e.g.getImageData/toDataURL/transferToImageBitmap). - `ImageBitmapRenderingContext`
- a context type whose contents come from a transferred
ImageBitmap(viatransferFromImageBitmap), which may itself be origin-unclean. - `context_->Reset()`
- resets the context’s backing store during a canvas size change.
Root Cause Analysis
On a canvas size change, the branch handling IsRenderingContext2D() || IsImageBitmapRenderingContext() called context_->Reset() and then unconditionally set origin_clean_ = true. For a 2D context a reset genuinely discards drawn content, so re-establishing a clean origin is defensible; but for an ImageBitmapRenderingContext the transferred bitmap’s taint must persist, and forcing origin_clean_ = true on resize cleared the cross-origin taint of content that was never legitimately re-created clean. As the commit notes, the spec does not say resizing should reset the image or mark the origin clean for this context type, so the invariant “a canvas tainted by cross-origin content stays tainted until clean content replaces it” was violated.
The fix guards the assignment with if (context_->IsRenderingContext2D()), so origin_clean_ is only reset to true for the 2D case and the bitmap-renderer case retains its taint. The regression test confirms that after transferring a tainted ImageBitmap and resizing, OriginClean() remains false while the context stays paintable.
origin_clean_ = true on resize for both context types, which laundered the cross-origin taint of an ImageBitmapRenderingContext; scoping the reset to IsRenderingContext2D() preserves the taint flag where it must persist.Attack Path
- Taint a bitmap
A page obtains a cross-origin / origin-unclean
ImageBitmap(the test setsSetOriginClean(false)on the source image). - Transfer into a bitmap-renderer canvas
The page calls
transferFromImageBitmapon anOffscreenCanvasusing anImageBitmapRenderingContext, making the canvas origin-unclean (OriginClean()false). - Resize the canvas
The page changes the canvas width/height (e.g.
setWidth(101)), reaching the resize branch that, pre-patch, runscontext_->Reset()and setsorigin_clean_ = true. - Read back cross-origin pixels
With the taint cleared, the page reads the now-“clean” canvas (e.g.
getImageData/toDataURL), exfiltrating cross-origin image data it should not be able to read.
Impact Assessment
ImageBitmapRenderingContext by resizing after transferring a tainted bitmap, enabling cross-origin image pixel disclosure from within the renderer. The advisory rates this medium severity (inappropriate implementation in Canvas) and does not report in-the-wild exploitation. The precondition is scripting ability to transfer a cross-origin-tainted ImageBitmap into a bitmap-renderer canvas and then resize it.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/modules/canvas/offscreencanvas/offscreen_canvas_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/offscreencanvas/offscreen_canvas.ccthird_party/blink/renderer/modules/canvas/offscreencanvas/offscreen_canvas_test.cc
Audit Directions
- Taint-flag resets on lifecycle eventsAudit resize/reset/clear paths that set
origin_clean_(or equivalent taint flags) totrue, confirming they only fire when content is genuinely replaced with same-origin data. - Context-type-agnostic branchesReview code that treats multiple context types in one branch (
IsRenderingContext2D() || IsImageBitmapRenderingContext()) for security decisions that are only valid for a subset. - Spec-divergent security state changesCross-check places that mutate same-origin/taint state against the HTML canvas security spec, flagging state transitions the spec does not sanction.
Patch
From d5009e4f67e453db324302c2960300ffe2164802 Mon Sep 17 00:00:00 2001
From: Colin Blundell <blundell@chromium.org>
Date: Wed, 01 Apr 2026 00:57:40 -0700
Subject: [PATCH] [Blink] Don't set origin_clean_ on ImageBitmapRendererContext resize
This came in here [1], which seems unintentional: that CL looks like it
was just ensuring that a CanvasResourceProvider created by the context
to store the image contents has its size properly updated after the
context's size changes. The spec does not indicate that origin_clean_
should be set here [2], or that the image should be reset [3].
[1] https://chromium-review.googlesource.com/c/chromium/src/+/5348468
[2] https://html.spec.whatwg.org/multipage/canvas.html#security-with-canvas-elements
[3] https://html.spec.whatwg.org/multipage/canvas.html#the-canvas-element:attr-canvas-width-5
Bug: 497821223
Change-Id: Ifa8c12a6c4a28199856db43767d2885c768bf258
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7710702
Reviewed-by: Vasiliy Telezhnikov <vasilyt@chromium.org>
Commit-Queue: Colin Blundell <blundell@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1608341}
---
diff --git a/third_party/blink/renderer/core/offscreencanvas/offscreen_canvas.cc b/third_party/blink/renderer/core/offscreencanvas/offscreen_canvas.cc
index 40b82f5c..292cf48 100644
--- a/third_party/blink/renderer/core/offscreencanvas/offscreen_canvas.cc
+++ b/third_party/blink/renderer/core/offscreencanvas/offscreen_canvas.cc
@@ -176,7 +176,9 @@
} else if (context_->IsRenderingContext2D() ||
context_->IsImageBitmapRenderingContext()) {
context_->Reset();
- origin_clean_ = true;
+ if (context_->IsRenderingContext2D()) {
+ origin_clean_ = true;
+ }
}
dirty_rect_for_commit_ = SkIRect::MakeWH(Size().width(), Size().height());
context_->DidDraw(CanvasPerformanceMonitor::DrawType::kOther);
diff --git a/third_party/blink/renderer/modules/canvas/offscreencanvas/offscreen_canvas_test.cc b/third_party/blink/renderer/modules/canvas/offscreencanvas/offscreen_canvas_test.cc
index 51cb4317..dd888e5 100644
--- a/third_party/blink/renderer/modules/canvas/offscreencanvas/offscreen_canvas_test.cc
+++ b/third_party/blink/renderer/modules/canvas/offscreencanvas/offscreen_canvas_test.cc
@@ -20,6 +20,7 @@
#include "third_party/blink/renderer/core/html/canvas/unique_font_selector.h"
#include "third_party/blink/renderer/core/imagebitmap/image_bitmap.h"
#include "third_party/blink/renderer/modules/canvas/htmlcanvas/html_canvas_element_module.h"
+#include "third_party/blink/renderer/modules/canvas/imagebitmap/image_bitmap_rendering_context.h"
#include "third_party/blink/renderer/modules/canvas/offscreencanvas2d/offscreen_canvas_rendering_context_2d.h"
#include "third_party/blink/renderer/platform/graphics/canvas_resource.h"
#include "third_party/blink/renderer/platform/graphics/gpu/shared_gpu_context.h"
@@ -28,9 +29,11 @@
#include "third_party/blink/renderer/platform/graphics/test/mock_compositor_frame_sink.h"
#include "third_party/blink/renderer/platform/graphics/test/mock_embedded_frame_sink_provider.h"
#include "third_party/blink/renderer/platform/graphics/test/test_webgraphics_shared_image_interface_provider.h"
+#include "third_party/blink/renderer/platform/graphics/unaccelerated_static_bitmap_image.h"
#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
#include "third_party/blink/renderer/platform/testing/task_environment.h"
#include "third_party/blink/renderer/platform/testing/testing_platform_support.h"
+#include "third_party/skia/include/core/SkSurface.h"
using ::testing::_;
using ::testing::Combine;
@@ -304,6 +307,40 @@
EXPECT_TRUE(SharedGpuContext::IsValidWithoutRestoringForTesting());
}
+TEST_F(OffscreenCanvasTest, BitmapRendererResizePreservesTaint) {
+ auto* canvas = MakeGarbageCollected<OffscreenCanvas>(
+ GetDocument().GetExecutionContext(), gfx::Size(100, 100));
+ CanvasContextCreationAttributesCore attrs;
+ auto* context = static_cast<ImageBitmapRenderingContext*>(
+ canvas->GetCanvasRenderingContext(
+ GetDocument().GetExecutionContext(),
+ CanvasRenderingContext::CanvasRenderingAPI::kBitmaprenderer, attrs));
+ ASSERT_NE(context, nullptr);
+
+ // Create a tainted ImageBitmap.
+ SkImageInfo info = SkImageInfo::MakeN32Premul(10, 10);
+ sk_sp<SkSurface> surface = SkSurfaces::Raster(info);
+ auto image =
+ UnacceleratedStaticBitmapImage::Create(surface->makeImageSnapshot());
+ image->SetOriginClean(false);
+ auto* bitmap = MakeGarbageCollected<ImageBitmap>(image);
+ EXPECT_FALSE(bitmap->OriginClean());
+
+ // Transfer tainted bitmap to canvas.
+ context->transferFromImageBitmap(bitmap, ASSERT_NO_EXCEPTION);
+ EXPECT_FALSE(canvas->OriginClean());
+ EXPECT_TRUE(context->IsPaintable());
+
+ // Resize the canvas.
+ canvas->setWidth(101);
+
+ // origin_clean_ should still be false.
+ EXPECT_FALSE(canvas->OriginClean());
+ EXPECT_TRUE(context->IsPaintable());
+
+ CanvasRenderingContext::GetCanvasPerformanceMonitor().ResetForTesting();
+}
+
const TestParams kTestCases[] = {
{false /* alpha */, false /* desynchronized */},
{false, true},
Regression Test / PoC
diff --git a/third_party/blink/renderer/modules/canvas/offscreencanvas/offscreen_canvas_test.cc b/third_party/blink/renderer/modules/canvas/offscreencanvas/offscreen_canvas_test.cc
index 51cb4317..dd888e5 100644
--- a/third_party/blink/renderer/modules/canvas/offscreencanvas/offscreen_canvas_test.cc
+++ b/third_party/blink/renderer/modules/canvas/offscreencanvas/offscreen_canvas_test.cc
@@ -20,6 +20,7 @@
#include "third_party/blink/renderer/core/html/canvas/unique_font_selector.h"
#include "third_party/blink/renderer/core/imagebitmap/image_bitmap.h"
#include "third_party/blink/renderer/modules/canvas/htmlcanvas/html_canvas_element_module.h"
+#include "third_party/blink/renderer/modules/canvas/imagebitmap/image_bitmap_rendering_context.h"
#include "third_party/blink/renderer/modules/canvas/offscreencanvas2d/offscreen_canvas_rendering_context_2d.h"
#include "third_party/blink/renderer/platform/graphics/canvas_resource.h"
#include "third_party/blink/renderer/platform/graphics/gpu/shared_gpu_context.h"
@@ -28,9 +29,11 @@
#include "third_party/blink/renderer/platform/graphics/test/mock_compositor_frame_sink.h"
#include "third_party/blink/renderer/platform/graphics/test/mock_embedded_frame_sink_provider.h"
#include "third_party/blink/renderer/platform/graphics/test/test_webgraphics_shared_image_interface_provider.h"
+#include "third_party/blink/renderer/platform/graphics/unaccelerated_static_bitmap_image.h"
#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
#include "third_party/blink/renderer/platform/testing/task_environment.h"
#include "third_party/blink/renderer/platform/testing/testing_platform_support.h"
+#include "third_party/skia/include/core/SkSurface.h"
using ::testing::_;
using ::testing::Combine;
@@ -304,6 +307,40 @@
EXPECT_TRUE(SharedGpuContext::IsValidWithoutRestoringForTesting());
}
+TEST_F(OffscreenCanvasTest, BitmapRendererResizePreservesTaint) {
+ auto* canvas = MakeGarbageCollected<OffscreenCanvas>(
+ GetDocument().GetExecutionContext(), gfx::Size(100, 100));
+ CanvasContextCreationAttributesCore attrs;
+ auto* context = static_cast<ImageBitmapRenderingContext*>(
+ canvas->GetCanvasRenderingContext(
+ GetDocument().GetExecutionContext(),
+ CanvasRenderingContext::CanvasRenderingAPI::kBitmaprenderer, attrs));
+ ASSERT_NE(context, nullptr);
+
+ // Create a tainted ImageBitmap.
+ SkImageInfo info = SkImageInfo::MakeN32Premul(10, 10);
+ sk_sp<SkSurface> surface = SkSurfaces::Raster(info);
+ auto image =
+ UnacceleratedStaticBitmapImage::Create(surface->makeImageSnapshot());
+ image->SetOriginClean(false);
+ auto* bitmap = MakeGarbageCollected<ImageBitmap>(image);
+ EXPECT_FALSE(bitmap->OriginClean());
+
+ // Transfer tainted bitmap to canvas.
+ context->transferFromImageBitmap(bitmap, ASSERT_NO_EXCEPTION);
+ EXPECT_FALSE(canvas->OriginClean());
+ EXPECT_TRUE(context->IsPaintable());
+
+ // Resize the canvas.
+ canvas->setWidth(101);
+
+ // origin_clean_ should still be false.
+ EXPECT_FALSE(canvas->OriginClean());
+ EXPECT_TRUE(context->IsPaintable());
+
+ CanvasRenderingContext::GetCanvasPerformanceMonitor().ResetForTesting();
+}
+
const TestParams kTestCases[] = {
{false /* alpha */, false /* desynchronized */},
{false, true},
Original Bug Report
SOP bypass in OffscreenCanvas resize with bitmaprenderer context
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: Resizing an OffscreenCanvas with a bitmaprenderer context incorrectly resets its origin_clean_ flag to true without actually clearing the underlying image data. This state desynchronization potentially allows an attacker to bypass the Same-Origin Policy and read cross-origin image data. An attacker could exploit this by transferring a tainted ImageBitmap to the canvas, resizing it, and then extracting the protected pixels.
Affected files:
third_party/blink/renderer/core/offscreencanvas/offscreen_canvas.ccthird_party/blink/renderer/modules/canvas/imagebitmap/image_bitmap_rendering_context.cc
Estimated timestamp from git blame: 2025-07-08
Description
A potential Same-Origin Policy (SOP) bypass exists in Blink’s OffscreenCanvas implementation when using the bitmaprenderer (ImageBitmap) rendering context. When an OffscreenCanvas is resized, its internal origin_clean_ flag is unconditionally reset to true if it uses a bitmaprenderer context. However, the actual image data stored in the rendering context is not cleared during this resize operation. This allows scripts to read back cross-origin pixel data that should be protected by the taint flag.
Note: The following steps and analysis are based on static code analysis; our tooling currently does not have the capability to run a live proof-of-concept.
Root Cause Analysis
The issue resides in OffscreenCanvas::SetSize within third_party/blink/renderer/core/offscreencanvas/offscreen_canvas.cc. During a resize, the following logic executes:
} else if (context_->IsRenderingContext2D() ||
context_->IsImageBitmapRenderingContext()) {
context_->Reset();
origin_clean_ = true;
}
While context_->Reset() is called, the implementation for ImageBitmapRenderingContext (third_party/blink/renderer/modules/canvas/imagebitmap/image_bitmap_rendering_context.cc) fails to clear the image stored in its ImageLayerBridge:
void ImageBitmapRenderingContext::Reset() {
CHECK(Host());
CHECK(Host()->IsOffscreenCanvas());
resource_provider_for_offscreen_canvas_.reset();
Host()->DiscardResources();
}
Because the image_layer_bridge_ is not cleared or disposed of, the OffscreenCanvas incorrectly reports its state as clean (OriginClean() == true) while the context still retains the tainted, cross-origin image pixels in memory.
Potential Attack Steps
An attacker could potentially trigger this vulnerability by following these steps:
- Load a cross-origin image (without CORS) from a victim site using an
HTMLImageElement(e.g., an authenticated avatar or private data). - Convert the cross-origin image into an
ImageBitmapusingcreateImageBitmap(img). This bitmap is correctly marked as not origin-clean (tainted). - Create an
OffscreenCanvasand obtain abitmaprenderercontext:const oc = new OffscreenCanvas(100, 100); const ctx = oc.getContext('bitmaprenderer'); - Transfer the tainted bitmap to the canvas via
ctx.transferFromImageBitmap(taintedBitmap). The canvas’sorigin_clean_flag is correctly set tofalse. - Resize the canvas to trigger the bug:
oc.width = 101;. This invokesSetSize, which incorrectly resetsorigin_clean_totruebut leaves the tainted pixels intact in the context. - Exfiltrate the pixel data by calling
await oc.convertToBlob(). SinceOriginClean()now incorrectly returnstrue, the security check passes, and the Blob is populated with the protected cross-origin pixels.
Suggested Fix
Modify ImageBitmapRenderingContext::Reset() to ensure the underlying image data is actually cleared. This can be done by replacing the image with a transparent black bitmap or dropping the reference in the layer bridge entirely. For example:
void ImageBitmapRenderingContext::Reset() {
CHECK(Host());
CHECK(Host()->IsOffscreenCanvas());
resource_provider_for_offscreen_canvas_.reset();
ResetInternalBitmapToBlackTransparent(Host()->width(), Host()->height()); // Clear the image
Host()->DiscardResources();
}
Alternatively, call image_layer_bridge_->Dispose() or image_layer_bridge_->SetImage(nullptr) if appropriate for the bridge’s lifecycle.
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.