[4] When captured as a video frame, canvas has to be tainted if cross-origin image are drawn into it
Canvas tainting blocked getImageData — nobody asked the capture path.
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
LayoutTests/http/tests/canvas/resources/cross-origin-image-capture-video-frame.html
Patch Details
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.
Background
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.
Analysis
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:
srcCanvas.captureStream(0)builds aCanvasCaptureMediaStreamTrackwhoseSourcestarts observing a canvas that is still origin-clean, andtrack.requestFrame()primes the track.- A cross-origin image is loaded from a different port/host (
http://localhost:8000/...inside a frame served fromhttp://127.0.0.1:8000/...) with nocrossoriginattribute, so no CORS approval is obtained. srcCtx.drawImage(img, 0, 0)clears the canvas's origin-clean flag; from this pointgetImageData()on the source throwsSecurityError.new ImageCapture(track).grabFrame()drives the on-demand path intoSource::grabFrame(), which pre-fix only checkedif (!canvas)and then calledcanvas->toVideoFrame()on the tainted 2D canvas (orgl->surfaceBufferToVideoFrame()for a WebGL context), returning aVideoFramecontaining the cross-origin pixels.- The resulting
ImageBitmapis drawn intodestCanvas, which was never touched by cross-origin content and therefore stays origin-clean; if theVideoFrame/ImageBitmappair 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.
Insight
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.
Audit directions
-
A security predicate checked by one accessor but omitted by a sibling accessor that materialises the same protected resource. This class is dangerous because the guard's presence in the obvious path creates false confidence, and the sibling is usually a newer or less-trodden API. Narrow: grep
Source/WebCorefor every producer of pixels out of aCanvasBase—toVideoFrame(,surfaceBufferToVideoFrame(,transferToImageBitmap,OffscreenCanvas::convertToBlob,createImageBitmapoverloads taking a canvas,HTMLCanvasElement::toDataURL/toBlob— and check that each referencesoriginClean()(orsecurityOrigin-based taint) before returning data; the match tell is a function returning image/frame data from a canvas whose body never namesoriginClean. Wider: the same shape appears wherever one resource has multiple extraction routes with per-route checks — media elementcaptureStream()vsMediaRecordervs WebRTC encode paths on a tainted<video>, WebGLreadPixelsvstexImage2Dround-trips,document.fonts/SVGImagerasterisation. The tell there is two call sites reaching the same backing store where only one consults the origin predicate. Widest: this is the general authorization-at-the-caller class — the check belongs at the single point where the protected bytes are produced, not replicated per entry point; carry it into Chromium's canvasOriginCleanhandling, filesystem APIs with parallel sync/async variants, and per-field-authorized GraphQL/REST resolvers. Tell: any codebase where a permission predicate appears N times for N callers instead of once in the shared accessor. -
Taint or provenance metadata that fails to cross a type boundary. When pixels leave
HTMLCanvasElementand become aVideoFrame, then anImageBitmap, the origin-clean bit lives on the object that was left behind, so the derived object is implicitly treated as clean. Narrow: trace every WebCore conversion that constructs aVideoFrame,ImageBitmap,NativeImage, orImageBufferfrom a canvas or a<video>and confirm the destination either carries origin metadata or the conversion is refused; start withSource/WebCore/Modules/mediastream/andSource/WebCore/html/ImageBitmap.cpp. Match tell: a constructor or factory whose input type has an origin/taint field and whose output type has no corresponding field. Wider: the same class covers any provenance flag attached to a wrapper rather than the payload — CORS mode onResponsevs the extractedArrayBuffer,crossOriginIsolatedstate vs transferredSharedArrayBuffers, secure-context flags vs cached objects. Widest: derived data inherits the confidentiality label of its source, or the derivation must be refused; this is classic information-flow labelling — the audit question to carry is "when this value changes type, who is holding the label now?" -
Time-of-check versus time-of-use on a mutable security flag. A canvas can be origin-clean when a stream is created and tainted later, and frames may be cached between production and consumption. Narrow: examine
ImageCapture.cpp's cachedRefPtr<VideoFrame> m_frame WTF_GUARDED_BY_LOCK(m_frameLock)andMediaStreamTrackPrivate's retained last-frame state, and determine whether a frame produced while the canvas was clean remains reachable after the canvas becomes tainted, and conversely whether any queued frame produced pre-fix could still be delivered. Match tell: a member that stores aVideoFrame/ImageBuffersnapshot whose only origin validation happened at production time. Wider: the same shape applies to any WebKit surface that caches a security-checked artifact — cached decoded images keyed before a CORS-mode change,ImageBitmaps held acrossdocument.open(), service-worker response caches. Widest: a security decision made at capture time must be re-validated at delivery time whenever the governing flag can transition — carry the question "can the predicate this cache entry was admitted under flip while the entry lives?"