← All reports

[4] When captured as a video frame, canvas has to be tainted if cross-origin image are drawn into it

HighWebCore Modules/mediastreamCrossOrigin

Canvas tainting blocked getImageData — nobody asked the capture path.

9391ef1

High. The canvas taint flag is the whole enforcement of same-origin policy over rendered pixels, and one of two frame-production paths never consulted it. No memory corruption, no race, no user interaction beyond loading a page — the read is deterministic and repeatable.

The canvas taint mechanism enforces the same-origin policy over pixels: once a cross-origin image is drawn into a canvas, its origin-clean flag clears and the read-back APIs (getImageData(), toDataURL(), toBlob()) throw SecurityError. Separately, captureStream() turns a canvas into a live MediaStreamTrack, so its output can be played in a <video> element, recorded, or snapshotted through ImageCapture. The expectation the flag encodes is that it is a property of the pixel data itself — unreadable by script through any route out of that canvas, not just the obvious one.

The angle: an attacker page can read back the pixels of any cross-origin image the victim's browser can fetch with cookies, by routing the canvas through the capture pipeline instead of getImageData().

HTMLCanvasElement::captureStream() allows streaming a canvas's output to a <video> element. The track frames of this video are obtained from CanvasCaptureMediaStreamTrack::Source::grabFrame(). This function unconditionally gets a VideoFrame by calling HTMLCanvasElement::toVideoFrame(). If cross-origin images are drawn into the canvas, this canvas has to be tainted, so no getImageData() can see the pixels of the cross-origin images.

Source/WebCore/Modules/mediastream/CanvasCaptureMediaStreamTrack.cpp

RefPtr<VideoFrame> CanvasCaptureMediaStreamTrack::Source::grabFrame()
{
if (!canvas)
return nullptr;
 
+ if (!canvas->originClean())
+ return nullptr;
+
#if ENABLE(WEBGL)
if (RefPtr gl = dynamicDowncast<WebGLRenderingContextBase>(canvas->renderingContext()))
return gl->surfaceBufferToVideoFrame(CanvasRenderingContext::SurfaceBuffer::DisplayBuffer);
#endif
...
void CanvasCaptureMediaStreamTrack::Source::captureCanvas()
{
- if (!canvas->originClean())
- return;
-
- RefPtr videoFrame = [&]() -> RefPtr<VideoFrame> {
-#if ENABLE(WEBGL)
- if (RefPtr gl = dynamicDowncast<WebGLRenderingContextBase>(canvas->renderingContext()))
- return gl->surfaceBufferToVideoFrame(CanvasRenderingContext::SurfaceBuffer::DisplayBuffer);
-#endif
- return canvas->toVideoFrame();
- }();
+ RefPtr videoFrame = grabFrame();
if (!videoFrame)
return;

LayoutTests/http/tests/canvas/resources/cross-origin-image-capture-video-frame.html

+ // 1. Capture the stream of the source canvas while it is still origin-clean.
+ var stream = srcCanvas.captureStream(0);
+ var track = stream.getVideoTracks()[0];
+ track.requestFrame();
+
+ // 2. Load the cross-origin image.
+ var img = new Image();
+ img.src = 'http://localhost:8000/canvas/resources/100x100-lime-rect.svg';
+ await new Promise(function(ok, fail) { img.onload = ok; });
+
+ // 3. Draw the the cross-origin image onto the canvas - this taints it; direct reads are now blocked.
+ var srcCtx = srcCanvas.getContext('2d');
+ srcCtx.drawImage(img, 0, 0);
+
+ // 4. grabFrame() snapshots the tainted canvas.
+ var bmp = await new ImageCapture(track).grabFrame();
+
+ // 5. Draw the frame to a fresh canvas - nothing should be drawn from the tainted canvas.
+ var destCtx = destCanvas.getContext('2d');
+ destCtx.drawImage(bmp, 0, 0);

Two changes in CanvasCaptureMediaStreamTrack.cpp. First, Source::grabFrame() gains an early-out: after resolving m_canvas into a RefPtr canvas, it returns nullptr when !canvas->originClean(), before either the WebGL path (gl->surfaceBufferToVideoFrame(CanvasRenderingContext::SurfaceBuffer::DisplayBuffer)) or the 2D path (canvas->toVideoFrame()) can produce a VideoFrame. Second, Source::captureCanvas() is refactored: its own if (!canvas->originClean()) return; guard and its inline lambda that duplicated the WebGL/2D frame-production logic are deleted, and it now simply calls RefPtr videoFrame = grabFrame();.

Net effect: the origin-clean check moves from one caller into the shared frame-production helper, so every consumer of Source::grabFrame() inherits it. Three layout tests are added — a reftest pair plus a cross-origin sub-resource that drives captureStream(0)track.requestFrame()drawImage(crossOriginImg)new ImageCapture(track).grabFrame()drawImage(bmp) into a second canvas; the expected result is that the third box stays red, i.e. no pixels arrive from the tainted canvas.

Security predicate enforced on one accessor while a sibling accessor reaching the same protected resource omits it.

Canvas origin-clean flag (tainting). Every canvas carries a boolean recording whether only same-origin (or CORS-approved) content has been drawn into it. HTMLCanvasElement::originClean() reports it, and the pixel read-back APIs consult it and throw SecurityError when it is false. Drawing a cross-origin image without CORS approval clears the flag.

HTMLCanvasElement::captureStream(frameRate). Returns a MediaStream whose single video track is backed by a CanvasCaptureMediaStreamTrack, letting canvas output be played in a <video> element or fed to MediaRecorder/WebRTC. A frameRate of 0 means no automatic frames; frames are emitted only when script calls track.requestFrame().

CanvasCaptureMediaStreamTrack::Source. A RealtimeMediaSource subclass. It observes the canvas (canvasChanged, canvasDisplayBufferPrepared) and schedules captureCanvas() on a zero-delay timer to push new frames into the track. Separately, Source::grabFrame() produces a VideoFrame on demand — either from the WebGL display buffer via surfaceBufferToVideoFrame() or from the 2D backing store via HTMLCanvasElement::toVideoFrame().

ImageCapture and ImageBitmap. ImageCapture is a binding constructed over a video MediaStreamTrack; its grabFrame() returns a promise resolving to an ImageBitmap snapshot of the track's current frame. ImageBitmap is an immutable bitmap accepted by CanvasRenderingContext2D::drawImage(); whether drawing one taints the destination canvas depends on the origin metadata carried by the bitmap's source.

Reftest structure. A -expected.html file renders the intended visual result with plain CSS boxes; the test passes only when the real test renders pixel-identically. Here the expected file paints the third box red, i.e. the frame-derived canvas must remain empty.

The taint invariant was enforced at exactly one of the two entry points into canvas frame production. Source::captureCanvas() — the timer/observer-driven path that pushes frames into the MediaStreamTrackPrivate for <video> playback — checked canvas->originClean() and bailed. Source::grabFrame() — the on-demand path reachable through the public CanvasCaptureMediaStreamTrack::grabFrame() forwarder (Ref source = static_cast<Source&>(this->source()); return source->grabFrame();) — performed only a null-canvas check and then unconditionally produced a VideoFrame.

  Before:                                    After:
  captureCanvas()      grabFrame()           captureCanvas()      grabFrame()
    ├─ originClean? ✓    ├─ canvas? ✓          └─► grabFrame() ──►  ├─ canvas? ✓
    └─► toVideoFrame()   └─► toVideoFrame()                          ├─ originClean? ✓
                              ▲                                      └─► toVideoFrame()
                              │
                     ImageCapture.grabFrame()
                     (tainted pixels escape here)

This is a logic error, not a memory-safety issue. The capture path launders the pixels through a different object graph — canvas → VideoFrame → (via ImageCapture) ImageBitmap. The ImageBitmap handed back to script originates from a same-document media track, so drawing it into a second, still-origin-clean canvas does not re-taint that canvas — the disclosure turns on the VideoFrame/ImageBitmap pair carrying no origin metadata forward, which the reftest exercises only as a visual outcome (third box red) rather than by asserting that read-back was permitted. Because grabFrame() never asked whether the source canvas was clean, the taint state simply did not travel with the pixels across that type boundary. The requestFrame()-before-taint ordering in the regression test is incidental to the leak: grabFrame() reads the canvas live at grab time, so the snapshot it takes contains the cross-origin image drawn a step earlier. The fix chooses hard denial — return nullptr, produce no frame at all — rather than taint propagation into the VideoFrame.

Reachability is from ordinary web content: no privileged API, no user gesture beyond loading the page. The regression test is itself a working trigger:

  1. srcCanvas.captureStream(0) builds a CanvasCaptureMediaStreamTrack whose Source starts observing a canvas that is still origin-clean, and track.requestFrame() primes the track.
  2. A cross-origin image is loaded from a different port/host (http://localhost:8000/... inside a frame served from http://127.0.0.1:8000/...) with no crossorigin attribute, so no CORS approval is obtained.
  3. srcCtx.drawImage(img, 0, 0) clears the canvas's origin-clean flag; from this point getImageData() on the source throws SecurityError.
  4. new ImageCapture(track).grabFrame() drives the on-demand path into Source::grabFrame(), which pre-fix only checked if (!canvas) and then called canvas->toVideoFrame() on the tainted 2D canvas (or gl->surfaceBufferToVideoFrame() for a WebGL context), returning a VideoFrame containing the cross-origin pixels.
  5. The resulting ImageBitmap is drawn into destCanvas, which was never touched by cross-origin content and therefore stays origin-clean; if the VideoFrame/ImageBitmap pair carries no origin metadata forward, destCtx.getImageData(0, 0, 100, 100) then returns the cross-origin pixel values — the reftest asserts only the visual outcome, not that read-back was permitted.

The reftest encodes step 5's intended post-fix behaviour visually (third box red = nothing drawn). A real attacker would substitute a credentialed cross-origin resource for the lime SVG and exfiltrate the decoded pixels with a plain fetch(). The WebGL branch offers the same route for surfaceBufferToVideoFrame() on a WebGL canvas tainted by a cross-origin texture upload. The attacker controls the source (any image URL the victim's browser can fetch, with cookies), the geometry (canvas size and draw transform), and the timing (grabFrame() is script-driven and repeatable). There is no memory corruption, no control-flow influence, and no write primitive.

Everything happens inside the WebContent process — this is a policy/isolation failure, not a sandbox boundary failure, and no escape is needed because the value of the bug is the data itself.

This vulnerability weakens the same-origin boundary as enforced by the canvas taint mechanism. The security model assumes that once a cross-origin image is drawn into a canvas, its pixels are unreadable by script through any route out of that canvas — the origin-clean flag is supposed to be a property of the pixel data, not of one particular accessor. Before the fix, an attacker page could read back the pixels of any cross-origin image it could load, including credentialed resources served to the victim's session such as profile photos, QR/barcode content, chart or document renderings, or SVG-rendered account data. The gain is cross-origin data disclosure of anything renderable as an image — a targeted read-only same-origin-policy bypass, not code execution.

Canvas taint is a property of the pixel data, but WebKit enforces it per-accessor, and Source had two accessors that both reached HTMLCanvasElement::toVideoFrame() — one guarded, one not. The fix's real content is not the three added lines but the refactor that makes captureCanvas() route through grabFrame(), collapsing two frame-production paths into one so a future third consumer cannot re-introduce the gap. Also worth noting the design choice: the fix denies the frame outright instead of propagating taint into the VideoFrame. That is the conservative option, but it means the taint flag still does not ride along with pixels once they leave the canvas type, so any other pipeline that materialises canvas pixels into a non-canvas type has the same latent problem and needs its own guard.