bf298c5fc5ca97d4e73fa764e72c54e055141af3 [html-in-canvas] Keep MailboxTextureBacking single-threaded
Triage note: GetCanvasSnapshot adds snapshot->MakeUnaccelerated() so a nested html-in-canvas snapshot isn't texture-backed before transfer to workers via ElementImage, preventing cross-thread use of the main thread's unsynchronized RasterContextProvider. Reachable via OffscreenCanvas.drawElementImage in a worker (Bug 569129173).
The bug at a glance
A single-threaded GPU-backed object (MailboxTextureBacking) bound to the main thread’s RasterContextProvider can be driven from worker threads via the html-in-canvas captureElementImage/ElementImage transfer path. Concurrent use of an unsynchronized GPU context from multiple threads is a classic data race on GPU command/sync-token state with memory-corruption potential; it is reachable from script in a renderer via OffscreenCanvas in a worker, so it rates high. The author’s decision to promote the thread checks from DCHECK to CHECK (shipping in release) signals they treat cross-thread use as a security-relevant invariant, not just a debug assumption.
html-in-canvas (content=drawable) lets a canvas snapshot a nested element. In the NESTED case, GetCanvasSnapshot in LocalFrameView recorded the nested canvas’s snapshot without first forcing it unaccelerated. captureElementImage then hands that snapshot’s backing (a StaticBitmapImage whose TextureBacking is a MailboxTextureBacking) to a worker via postMessage transfer. The MailboxTextureBacking holds a main-thread RasterContextProvider and is single-thread-affine, so touching it on the worker thread lets two threads use the main thread’s unsynchronized raster context at once.
Root cause
LocalFrameView’s nested-canvas snapshot path called nested_canvas->Snapshot(kBackBuffer) and used the result directly in a PaintRecordBuilder. When the nested canvas is GPU-accelerated the returned StaticBitmapImage is texture-backed by a MailboxTextureBacking that caches a scoped_refptrviz::RasterContextProvider (context_provider_) and a thread_checker_, both bound to the main thread. Transferring that image to a worker via ElementImage/captureElementImage moves the backing across threads; the worker’s drawElementImage/readback then calls into MailboxTextureBacking methods (GetSkImageViaReadback, ReadbackImagePixels) which invoke context_provider_->RasterInterface() on the main thread’s context from the worker thread, so two threads share an unsynchronized RasterContextProvider. The fix makes the snapshot safe to serialize by calling snapshot->MakeUnaccelerated() (mirroring HTMLCanvasElement::PaintInternal’s non-nested path) so the transferred image is CPU-backed and carries no MailboxTextureBacking, and DCHECK(!snapshot->IsTextureBacked()) documents the new invariant. To harden, the latent DCHECK_CALLED_ON_VALID_THREAD guards in MailboxTextureBacking were converted to CHECK(thread_checker_.CalledOnValidThread()) in the dtor, Bind, Unbind, GetSkImageViaReadback and ReadbackImagePixels, and the THREAD_CHECKER macro was replaced with a release-present base::ThreadCheckerImpl so the affinity is enforced in shipping builds.
Key code
LocalFrameView nested-canvas snapshot path: force CPU backing before the snapshot can be transferred to a worker
scoped_refptr<StaticBitmapImage> snapshot =
nested_canvas->Snapshot(kBackBuffer);
if (snapshot) {
// GraphicsContext cannot handle gpu resource serialization.
snapshot = snapshot->MakeUnaccelerated();
}
if (snapshot) {
DCHECK(!snapshot->IsTextureBacked());
PaintRecordBuilder builder;
Patch walkthrough
third_party/blink/renderer/core/frame/local_frame_view.cc— The root-cause fix. The nested html-in-canvas snapshot path previously fed nested_canvas->Snapshot(kBackBuffer) straight into a PaintRecordBuilder. The patch splits that into a two-step sequence: take the snapshot, then if non-null call snapshot->MakeUnaccelerated() so any GPU/texture backing (the MailboxTextureBacking) is converted to a CPU-backed bitmap before it can be recorded and later transferred to a worker. A DCHECK(!snapshot->IsTextureBacked()) asserts the result is no longer texture-backed, matching the comment that GraphicsContext cannot serialize GPU resources.third_party/blink/renderer/platform/graphics/mailbox_texture_backing.cc— Hardening. Five DCHECK_CALLED_ON_VALID_THREAD(thread_checker_) guards are promoted to CHECK(thread_checker_.CalledOnValidThread()) in the destructor, GetSkImageViaReadback and ReadbackImagePixels, and brand-new CHECK thread guards are added to the previously-unguarded Bind and Unbind methods. These now crash (instead of silently continuing in release) if the single-thread-affine backing is touched off its owning thread.third_party/blink/renderer/platform/graphics/mailbox_texture_backing.h— Replaces the THREAD_CHECKER(thread_checker_) macro member (which compiles to an empty/no-op checker in release builds) with a concrete base::ThreadCheckerImpl thread_checker_, so the CalledOnValidThread() checks above are actually enforced in shipping Chrome, not just debug.third_party/blink/web_tests/external/wpt/html/canvas/offscreen/manual/draw-element-image/offscreenCanvas-drawElementImage-worker.tentative.html— Extends the manual WPT to add a nested, GPU-accelerated (webgl2, cleared to blue), captures it with canvas.captureElementImage(webgl_child), transfers it to a worker, and asserts the worker reads back pixels [0,0,255,255] at 100x100. This exercises exactly the nested-accelerated-snapshot-to-worker path that previously carried the MailboxTextureBacking across threads.
Background
html-in-canvas (content=drawable / captureElementImage) — Experimental Blink feature letting a declare drawable child elements and snapshot them into an ElementImage; the image can be posted to a worker and drawn with OffscreenCanvas.drawElementImage, which is what carries a renderer-side image backing across threads.
MailboxTextureBacking — A cc::TextureBacking implementation that wraps a GPU shared image via a mailbox. It caches a viz::RasterContextProvider (context_provider_) and is single-thread-affine (guarded by thread_checker_); its RasterInterface is tied to one thread and is not internally synchronized for concurrent use.
StaticBitmapImage::MakeUnaccelerated / IsTextureBacked — StaticBitmapImage may be GPU(texture)-backed or CPU-backed. MakeUnaccelerated() reads the pixels back into a CPU bitmap; IsTextureBacked() reports whether a GPU backing (and thus a MailboxTextureBacking) is still attached.
DCHECK vs CHECK and THREAD_CHECKER vs ThreadCheckerImpl — DCHECK/THREAD_CHECKER compile to no-ops in release builds, so a thread-affinity violation would go undetected in shipping Chrome; CHECK/base::ThreadCheckerImpl are present in release and abort on violation, turning a silent race into a controlled crash.
Vulnerability window
- Introduction — Latent in the html-in-canvas nested-snapshot path: the non-nested HTMLCanvasElement::PaintInternal already made snapshots unaccelerated, but the nested GetCanvasSnapshot branch in LocalFrameView omitted that step, so a GPU-backed MailboxTextureBacking could survive into the transferable ElementImage.
- Report — Tracked as Chromium issue 569129173 (Fixed: 569129173).
- Fix landed — Committed 2026-10-09 by Philip Rogers (CL 8549355), Cr-Commit-Position refs/heads/main@{#1716006}, reviewed by Stefan Zager.
Proof of concept
The diff adds a concrete (manual) WPT that reproduces the triggering path: a nested GPU-accelerated webgl2 canvas is captured via captureElementImage and transferred to a worker, where it is drawn and read back. This is web-facing and script-reachable wherever the html-in-canvas / drawElementImage feature is enabled. It is a race against the main thread’s GPU context, so whether it corrupts vs merely trips the new CHECK is nondeterministic; the test asserts correct pixels on the fixed build rather than demonstrating corruption. It is marked manual/tentative and needs GPU acceleration enabled.
// WPT (manual) added by the patch:
const gl = webgl_child.getContext('webgl2');
gl.clearColor(0, 0, 1, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
await waitForCanvasPaint(canvas);
// ... spin up worker ...
let webglElementImage = canvas.captureElementImage(webgl_child);
worker.postMessage({ elementImage: webglElementImage }, [webglElementImage]);
// worker draws via drawElementImage and reads back pixels, expecting [0,0,255,255]
Exploitation
- Setup — In a page with html-in-canvas enabled, create a parent with a nested GPU-accelerated child canvas (webgl2), and a dedicated worker. (inferred: feature must be enabled, e.g. via flag/origin trial in the targeted build.)
- Capture & transfer — Call canvas.captureElementImage(child) for the accelerated child and postMessage the ElementImage to the worker with transfer. Pre-fix, the transferred StaticBitmapImage remains texture-backed by a main-thread MailboxTextureBacking.
- Cross-thread GPU use (inferred) — On the worker, drawElementImage / readback invokes MailboxTextureBacking methods that call context_provider_->RasterInterface() — the main thread’s unsynchronized raster context — concurrently with main-thread GPU activity, producing a data race on GPU command/sync-token state.
- Corruption/escalation (inferred) — Unsynchronized concurrent use of a RasterContextProvider across two threads can corrupt the command stream, sync tokens or shared-image state; downstream memory-safety impact in the GPU/raster path is not shown by the patch and is inferred. No concrete primitive is demonstrated.
Detection & hunting
For defenders and detection engineers:
- MailboxTextureBacking thread CHECK aborts —
- ElementImage transfer of a texture-backed snapshot —
- Worker-thread entry into main-thread RasterContextProvider —
Audit directions
- Other Snapshot()->serialize paths —
- Single-thread-affine GPU backings crossing threads —
- DCHECK-only thread guards on GPU objects —