[4] Canvas2D filter switcher UAF under save-stack exhaustion
The RAII guard that trusted save() to always make room.
High. A RAII helper's entire lifetime guarantee was encoded in the assumption that save() always pushes a new state — an assumption that silently fails at the stack cap, replacing a live drawing context in place. The web-reachable UAF is unconditional once the stack is exhausted; escalation to a stronger primitive rides on reclaiming the freed slot under heap grooming.
WebKit's 2D canvas maintains a stack of drawing states, and a class of lifetime bug appears when a scoped helper's ownership guarantee is implicitly encoded in a side effect that has a silent failure mode. CanvasRenderingContext2D manipulates that stack via save()/restore(), and each state can hold a targetSwitcher — a CanvasLayerContextSwitcher, a RefPtr-managed object that routes drawing through a filter graph. CanvasFilterContextSwitcher is a scoped RAII helper that, for the duration of a filtered draw, installs a layer switcher as the context's active targetSwitcher and removes it on destruction, on the assumption that its save() created a fresh, isolated state slot to write into.
The angle: script can saturate the 2D save-stack past its cap, then set context.filter and draw, freeing a live drawing context out from under an in-flight filtered draw — a use-after-free in the process hosting canvas rendering, usable as a memory-corruption foundation under controlled heap conditions.
CanvasRenderingContext2D limits state on the stack to MaxStackSize (currently 16384). If the limit is reached, save() is a no-op and pushes nothing. This is fine for most code, but breaks CanvasFilterContextSwitcher, which requires a state save to change targetSwitcher. When the switcher is created it saves the current state so it can swap targetSwitcher later; if the stack is exhausted, no state is pushed, and the new layer switcher overwrites the same top state that held the previous switcher. The previous switcher, if solely owned by that slot, is freed — but the new switcher holds a reference to the drawing context of the switcher just freed.
The patch fixes this by eliminating the state save. Instead, CanvasFilterContextSwitcher holds onto the old targetSwitcher when created, replaces it with the new one, and on destruction puts the old one back — so filtered drawing works regardless of stack exhaustion.
Source/WebCore/html/canvas/CanvasFilterContextSwitcher.cpp
Source/WebCore/html/canvas/CanvasFilterContextSwitcher.h
LayoutTests/fast/canvas/canvas-filtered-drawing-after-stack-exhaustion.html
Patch Details
The patch reworks how CanvasFilterContextSwitcher swaps the active targetSwitcher. Previously create constructed the filter switcher (whose constructor called context.save() + context.realizeSaves() to push a fresh state), created a CanvasLayerContextSwitcher, and assigned it into context.modifiableState().targetSwitcher; the destructor called restore() to pop. The save()/realizeSaves()/restore() dance is deleted entirely.
The constructor signature becomes CanvasFilterContextSwitcher(CanvasRenderingContext2DBase&, const FloatRect&, RefPtr<Filter>&&) and now captures the previous switcher into a new member RefPtr<CanvasLayerContextSwitcher> m_contextOldTargetSwitcher(context.state().targetSwitcher) before overwriting modifiableState().targetSwitcher with the newly created layer switcher (only if non-null). The destructor restores the saved switcher via m_context->modifiableState().targetSwitcher = WTF::move(m_contextOldTargetSwitcher). m_context becomes const WeakRef, and the header adds #include "Filter.h". Two layout tests exercising filtered drawing after save-stack exhaustion are added.
A lifetime invariant that depended on an unconditional state push breaks when the push is capped, so a resource is replaced in place while a live consumer still references it.
Background
The 2D state stack.
CanvasRenderingContext2D maintains a stack of drawing states manipulated by save()/restore(), capped at MaxStackSize (16384). Once full, save() is a documented no-op that pushes nothing.
Target switchers.
Each state can hold a targetSwitcher — a CanvasLayerContextSwitcher, a RefPtr-managed object used to route drawing through a filter. CanvasFilterContextSwitcher is a scoped RAII helper: for the duration of a filtered draw it installs a layer switcher as the context's active targetSwitcher and removes it when destroyed.
How a layer switcher borrows its context.
CanvasLayerContextSwitcher::create obtains context.effectiveDrawingContext() — the drawing context of the currently active switcher — and hands it to a GraphicsContextSwitcher, which retains a reference to it.
Triggering a filtered draw.
Setting context.filter to a non-none value and then issuing a draw call (fillRect, say) triggers construction of a CanvasFilterContextSwitcher for that draw.
Analysis
The root cause is a use-after-free created by an in-place replacement that was supposed to be a push onto a fresh slot.
Stack NOT full (intended) Stack full, save() no-ops (bug)
───────────────────────── ───────────────────────────────
save() -> push new state save() -> no-op, no push
new switcher written to NEW top new switcher written to SAME top
old switcher stays in prior state old switcher was sole owner -> freed
old drawing context alive new switcher borrows freed ctx <- UAF
Before the fix, CanvasFilterContextSwitcher relied on context.save() pushing a new state so its assignment to modifiableState().targetSwitcher would land on a fresh top-of-stack state, leaving the previous state's targetSwitcher (and the drawing context it owns) intact. Once the 16384-entry cap is reached, save() silently becomes a no-op. In that condition the write to modifiableState().targetSwitcher overwrites the same top state that already held the previous switcher. CanvasLayerContextSwitcher::create reads context.effectiveDrawingContext() — the drawing context owned by that same old top-of-stack switcher — and the new GraphicsContextSwitcher retains a reference to it. When the new switcher is then assigned into the unchanged top state, the old switcher, if solely owned by that slot, is destroyed, freeing its drawing context; the new switcher now holds a dangling reference, and subsequent filtered draw operations dereference it.
The regression test drives exactly this regime: it calls context.save() 20000 times to saturate the stack, then sets context.filter to a drop-shadow and issues fillRect draws. The bug being reachable only in the stack-exhausted regime points to targeted edge-case auditing of the save-stack cap — or a stress test issuing tens of thousands of save() calls before filtered drawing — rather than ordinary random fuzzing.
The immediate observable effect is a dangling-reference dereference during filtered drawing (a crash or controlled invalid access). To escalate, an attacker would reclaim the freed drawing-context allocation with controlled data via heap grooming — allocating like-sized objects immediately after the free — so the dangling reference lands on attacker-influenced memory when the filter graph consumes it. Canvas 2D drawing executes in the process hosting the rendering context (WebContent, or the GPU process when canvas is offloaded); a successful exploit gives a primitive there, and a separate escape would still be required to leave the sandbox.
This vulnerability weakens memory safety within the canvas rendering pipeline. The model assumes a switcher feeding a filter graph cannot dangle against the drawing context it consumes; the pre-fix code violated this whenever the 2D state stack was exhausted. The fix removes the indirection and makes ownership explicit by holding the old switcher in a RefPtr member, which both closes the UAF and makes the intent legible.
Audit directions
- Scoped/RAII helpers whose correctness depends on a state-mutating call that has a silent capped or best-effort failure mode (here
save()no-ops atMaxStackSize). The invariant: the operation I assume created a fresh, isolated slot actually created one. Narrow: grepWebCore/html/canvasfor othersave()/realizeSaves()/restore()pairs ormodifiableState()writes that assume a distinct state was pushed — start withCanvasRenderingContext2DBase's own layer/filter setup. Match tell: a write intomodifiableState().Xbracketed by asave/restorewhere nothing re-checks that the stack actually grew. - An object that retains a borrowed reference to a resource owned by another object that can be replaced in place. The invariant: the producer of a borrowed drawing context must outlive every consumer that captured it. Wider: audit
GraphicsContextSwitcher/CanvasLayerContextSwitcherand any factory takingeffectiveDrawingContext()or aGraphicsContext&and storing it — verify the source context's lifetime is anchored by aRef/RefPtrfor as long as the consumer lives, not by a stack slot that can be overwritten. Match tell: a constructor parameter of typeGraphicsContext&/DrawingContext&stored without a corresponding owning ref on the object. - Capacity-capped stacks/pools whose overflow behavior is "silently drop" rather than "fail loudly." Widest: this generalizes to any container with a bounded-push-becomes-noop policy — clamped graphics-state save/restore stacks, or general RAII guards paired with fixed-capacity pools. Enumerate the callers of
save()in canvas code and confirm each tolerates the no-op path; the portable invariant is that if push can silently fail, every downstream reference that assumed the push succeeded is a potential aliasing/UAF hazard.