← All reports

[1] Canvas 2D `beginLayer()` use-after-free on save-stack overflow

HighWebCore HTML canvasUAF

Two iframes, two Set prototypes, and the compiled code watching the wrong one.

e055837

High, and not hypothetical — a deterministic crash is the established effect. A second beginLayer() at the save cap frees a GraphicsContext that the freshly-built layer switcher has already cached as a raw pointer; stronger primitives depend only on what the attacker can reclaim into that freed allocation.

The HTML canvas 2D context keeps a stack of drawing State frames, and implements save() lazily: a counter is bumped and the real frame is only materialized later by realizeSaves(). The canvas-layers feature (beginLayer()/endLayer()) redirects drawing into an intermediate buffer by storing a CanvasLayerContextSwitcher — the per-layer redirector built on GraphicsContextSwitcher — into the top State's targetSwitcher slot. The invariant the subsystem relies on: each layer owns its own State frame, so its switcher slot starts empty and the layer below stays alive in the frame beneath it.

The angle: a page that opens canvas layers up to the save-stack cap and then calls beginLayer() once more can free the active layer's backing GraphicsContext while a live switcher still holds a raw pointer to it, turning subsequent canvas draws into stale-pointer dereferences.

Commit message: beginLayer() treated realizeSaves() as infallible. When the state stack has already reached MaxSaveCount, realizeSavesLoop() logs "Ignoring save()." and returns without growing the stack, leaving m_unrealizedSaveCount nonzero. The fix bails out of beginLayer() in that condition.

Source/WebCore/html/canvas/CanvasRenderingContext2DBase.cpp

void CanvasRenderingContext2DBase::beginLayer(...)
{
+ if (m_unrealizedSaveCount)
+ return;
...
state().targetSwitcher = CanvasLayerContextSwitcher::create(*this, ...);
}

The change adds a single guard at the top of beginLayer(): if there are pending (unrealized) saves — the state that realizeSavesLoop() leaves behind when it refuses to push past MaxSaveCount — the function returns before constructing a new layer switcher. Nothing else in the layer-construction path is altered; the fix restores the "one layer, one frame" invariant by refusing to build a layer when the frame it would own could not be created.

A bounded push whose failure is unchecked lets a new owner overwrite an owning slot, freeing an object a sibling has already cached by raw pointer.

Where this lives. CanvasRenderingContext2DBase is the shared implementation behind CanvasRenderingContext2D, OffscreenCanvasRenderingContext2D, and PaintRenderingContext2D, all running in the WebContent process.

The lazy save stack. save() is { ++m_unrealizedSaveCount; }. The real frames are copies of the top State pushed onto m_stateStack by realizeSaves()/realizeSavesLoop(). realizeSavesLoop() is bounded: at MaxSaveCount it emits a console warning and returns without pushing.

The layer switcher. CanvasLayerContextSwitcher::create() first calls context.effectiveDrawingContext() and caches the result in the raw member GraphicsContext* m_effectiveDrawingContext, alongside a std::unique_ptr<GraphicsContextSwitcher> m_targetSwitcher. The switcher is stored in the top State via a RefPtr.

RefPtr ownership. When a RefPtr to the sole-owned switcher is overwritten, the previous switcher's refcount hits zero and ~CanvasLayerContextSwitcher() runs immediately, destroying its owned GraphicsContextSwitcher and the backing GraphicsContext.

The bug is a use-after-free driven by an unchecked bounded push. On the second beginLayer() at the cap, construction order is decisive.

  beginLayer() #2 at MaxSaveCount
  ────────────────────────────────
  create() → effectiveDrawingContext()  → resolves to LAYER #1's ctx
           → cache raw m_effectiveDrawingContext = ctx(#1)   [new switcher]
  state().targetSwitcher = new switcher  → releases LAYER #1 switcher
           → ~CanvasLayerContextSwitcher(#1)
                 → destroys unique_ptr<GraphicsContextSwitcher>
                 → frees ctx(#1)                              ← now freed
  new switcher.m_effectiveDrawingContext ──► ctx(#1)          ← dangles

Because the stack never grew, the new switcher is stored into the same top State that still holds layer #1's switcher. Assigning the new RefPtr releases layer #1's switcher, which — being sole-owned — destructs, tearing down the very GraphicsContext the new switcher just cached as a raw pointer.

Every subsequent use of the new switcher dereferences the dangling pointer: drawingContext() does m_targetSwitcher->drawingContext(*m_effectiveDrawingContext), and ~CanvasLayerContextSwitcher() does m_targetSwitcher->endDrawSourceImage(*m_effectiveDrawingContext, ColorSpace::SRGB()). Both dereference sites are confirmed in the supplied CanvasLayerContextSwitcher.cpp. In the layout test, the third beginLayer() forces layer #2's destructor to run over its already-freed context.

CanvasLayerContextSwitcher::drawingContext() does m_targetSwitcher->drawingContext(*m_effectiveDrawingContext) and the destructor does m_targetSwitcher->endDrawSourceImage(*m_effectiveDrawingContext, ColorSpace::SRGB()), so both a per-draw and a teardown dereference run through the dangling member. The state().targetSwitcher = ... assignment and the slot name come from the commit message; the friend class CanvasLayerContextSwitcher declaration and the RefPtr-returning create() are consistent with it.

The established effect is a deterministic crash on the stale dereference. Escalation to a stronger primitive requires reclaiming the freed GraphicsContext allocation with attacker-controlled contents between the free and the next draw; that is a projected direction and depends on heap reclamation this change does not establish. Whether CanvasLayersEnabled is exposed in a given configuration is a separate question that gates reachability.