← WebKit — 2026-W37

fe938b0a2301d3d3938b2f9f3c8cad60c7c2e1a6  Make CanvasRenderingContext2DBase::FontProxy ref-counted

severity high class UAF confidence 0.82 signal score 9.47 Source/WebCore/html patch-now AI review CONFIRMED
Primitive: Stale FontSelectorClient pointer from by-value FontProxy copied into Vector<State> that relocates
Triage note: The fix (the #else branch, old buggy code guarded by RC_REMOVE_SEC_ACCEL_3) converts CanvasRenderingContext2DBase::State::FontProxy from an embedded by-value member (FontProxy font) that is a FontSelectorClient into a heap-allocated Ref<FontProxy> with create()/clone(), and changes realizeSavesLoop to m_stateStack.append(state().clone()) instead of appending a by-value copy. A by-value FontProxy registered with a FontSelector gets a non-stable address when the State is copied or when Vector<State,1> m_stateStack relocates, leaving the FontSelector holding a dangling FontSelectorClient pointer that is dereferenced on fontsNeedUpdate; the RC_REMOVE_SEC_ACCEL_3 bracketing marks this as the silent security accelerant. Canvas 2D font/save state is directly web-reachable.
Structural signals: smartptr_member_added (+6.47), msg_strong (+3.0), area:Source/WebCore/html (+2.0), large_churn (-2.0)

The bug at a glance

WebCore Canvas 2D use-after-free. CanvasRenderingContext2DBase::State embedded a FontProxy by value, and FontProxy is a FontSelectorClient that registers itself with a FontSelector by its address. Because State is copied into Vector<State, 1> m_stateStack on save() and the inline-capacity Vector relocates its storage as it grows, the by-value FontProxy’s address changes while the FontSelector still holds the old pointer, which it later dereferences in fontsNeedUpdate() -> a dangling FontSelectorClient. The fix heap-allocates FontProxy as Ref with create()/clone() so its address is stable, and clones through State::clone() on the save path. Canvas 2D font and save/restore are directly web-reachable, making this a solid script-reachable UAF.

A FontSelector tracks its clients by raw pointer, but the client was an inline member of a value type copied into a relocating Vector, so saving canvas state moved the client and left the selector with a stale address.

Root cause

The invariant is that a FontSelectorClient registered with a FontSelector must have a stable address for as long as it stays registered, because the selector stores raw client pointers and calls back into them (fontsNeedUpdate) on font changes. Pre-patch FontProxy was class FontProxy final : public FontSelectorClient held as a by-value member FontProxy font; inside struct State, and canvas drawing state is kept in Vector<State, 1> m_stateStack.

Pre-patch sequence: 1. setFontWithoutUpdatingStyle calls modifiableState().font.initialize(fontSelector, fontCascade), which registers the FontProxy (a FontSelectorClient) with the document’s FontSelector at the member’s current address. 2. Script calls canvas save(); realizeSavesLoop does m_stateStack.append(state()) - a by-value copy of State, producing a FontProxy at a new address (and, as the inline-capacity-1 Vector grows beyond its inline slot, relocating existing State elements to a new buffer, again moving their FontProxy members). 3. The FontSelector still holds the pointer(s) to the old FontProxy address(es). 4. On a font-cache invalidation the FontSelector iterates its clients and calls fontsNeedUpdate() on a FontProxy whose storage has moved/been destroyed -> use-after-free / dangling-pointer dereference. The FontProxy destructor unregisters (if realized()), but copies/relocations create registered aliases at stale addresses.

The fix converts FontProxy to class FontProxy final : public RefCounted<FontProxy>, public FontSelectorClient with a private copy ctor and a public static create()/NODELETE clone(), and changes State to hold Ref<FontProxy> font; (a heap object with a stable address) initialized via FontProxy::create(). State gains an explicit clone() that deep-copies the FontProxy via this->font->clone() and is used by realizeSavesLoop (m_stateStack.append(state().clone())) instead of a by-value append; State’s copy ctor is made private and move ops defaulted so accidental value copies that would duplicate the client are inhibited. All accessors are updated from state().font.X to protect(state().font)->X / fontProxy.ptr(). Now the FontSelectorClient lives on the heap at a fixed address regardless of State copies or Vector relocation, so the selector’s stored pointer stays valid; cloning creates a fresh independently-registered FontProxy for the saved state. This matches the triage UAF bugclass.

Key code

FontProxy becomes heap-allocated/ref-counted with a stable address; saves clone instead of value-copy

class FontProxy final : public RefCounted<FontProxy>, public FontSelectorClient {
public:
    static Ref<FontProxy> create();
    Ref<FontProxy> NODELETE clone() const;
...
// struct State:
    Ref<FontProxy> font;            // was: FontProxy font;

auto CanvasRenderingContext2DBase::State::clone() const -> State
{
    State cloned = *this;
    cloned.font = protect(this->font)->clone();   // fresh, independently-registered client
    return cloned;
}
// realizeSavesLoop:
    m_stateStack.append(state().clone());         // was: m_stateStack.append(state());

Fix approach

The patch gives the FontSelectorClient a stable identity by moving FontProxy off the State object and onto the heap: FontProxy is now RefCounted with create()/clone(), State holds Ref font, and the default State initializes font(FontProxy::create()). Saving canvas state no longer value-copies the client; State::clone() makes a fresh heap FontProxy via clone() so each stack entry has its own independently-registered, fixed-address client. State’s copy constructor is privatized (move ctor/assignment defaulted) so only clone() can duplicate a State, preventing accidental copies that would register a client at a soon-to-move address. Every use site is rewritten to go through the Ref (protect(state().font)->…, fontProxy.ptr(), RefPtr fontProxy = this->fontProxy()). The semantic change is that FontProxy lifetime/registration is now decoupled from Vector storage and relocation.

Patch walkthrough

  • Source/WebCore/html/canvas/CanvasRenderingContext2D.cpp — Rewrites font accessors (setFontWithoutUpdatingStyle, fontProxy()) to operate through the ref-counted FontProxy (protect(state().font)->realized(), state().font.ptr(), protect(modifiableState().font)->initialize(…)).
  • Source/WebCore/html/canvas/CanvasRenderingContext2DBase.cpp — Initializes State::font with FontProxy::create(), adds State::clone() (deep-copies font via clone()), adds FontProxy::create()/clone(), and changes realizeSavesLoop to append state().clone(); updates fontString/drawTextUnchecked/measureText/textOffset/setLetter&WordSpacing to use the Ref.
  • Source/WebCore/html/canvas/CanvasRenderingContext2DBase.h — Makes FontProxy inherit RefCounted, declares create()/clone() and privatizes the copy ctor/assignment; changes State::font to Ref, privatizes State’s copy ctor, defaults its move ops, and declares State::clone().
  • Source/WebCore/html/canvas/OffscreenCanvasRenderingContext2D.cpp — Mirrors the C++ accessor rewrites for the offscreen context (setFont, fontProxy()) to use the ref-counted FontProxy via protect(state().font)->…

Background

FontSelector / FontSelectorClient — A FontSelector notifies registered FontSelectorClients (by raw pointer) when fonts become available/invalid via fontsNeedUpdate(); clients must outlive their registration and keep a fixed address.

CanvasRenderingContext2DBase::State — The value struct holding one canvas drawing-state snapshot (styles, transforms, font); copied onto a stack by save()/restore().

Vector<State, 1> m_stateStack — The save/restore stack with inline capacity 1; appending beyond the inline slot reallocates the backing buffer, relocating (moving) all existing State elements and any by-value members’ addresses.

FontProxy — Canvas’s adapter owning a realized FontCascade and acting as a FontSelectorClient; pre-patch an inline State member, post-patch a heap RefCounted object with create()/clone().

Ref / RefCounted / protect() — WebKit ref-counting: RefCounted gives an object a stable heap identity and lifetime tied to Ref/RefPtr references; protect() takes a temporary strong ref so a raw accessor result stays alive across use.

Vulnerability window

  1. Setup — Script sets ctx.font on a canvas 2D context, realizing the FontProxy and registering it with the document FontSelector.
  2. Trigger — Script calls ctx.save() (possibly repeatedly), which appends State copies and grows Vector<State,1> past its inline slot, relocating State storage.
  3. Dangle — The relocated/copied by-value FontProxy leaves the FontSelector holding a pointer to a moved-from/old address.
  4. Use — A font availability/invalidation event makes the FontSelector call fontsNeedUpdate() on the stale FontProxy pointer -> UAF.
  5. Mitigated — Post-patch the heap Ref keeps each client at a fixed address and clone() gives saved states their own client.

Proof of concept

No regression test ships. Conceptual trigger (web path): on a 2D canvas context, set ctx.font (realizing+registering the FontProxy with the document FontSelector), then call ctx.save() enough times to grow the state stack past its inline capacity so State storage relocates; ensure a font-set load or web-font availability change fires fontsNeedUpdate() on the FontSelector’s clients. Pre-patch the selector dereferences a FontProxy whose address moved (or whose copy outlived at a stale address). Using a @font-face/web font that resolves asynchronously makes the fontsNeedUpdate callback a controllable trigger.

Exploitation

  1. Reach — Canvas 2D font + save/restore and web-font loading are all plain web APIs executed in WebContent.
  2. Trigger — Realize a FontProxy, then relocate/copy State (save()) so a registered client address goes stale, and fire a font-cache invalidation.
  3. Primitive — Use-after-free / dangling-pointer call: FontSelector invokes fontsNeedUpdate() on a FontProxy (a FontSelectorClient) at freed/moved storage.
  4. Reclaim (inferred) — Escalation past a crash needs reclaiming the vacated FontProxy storage with controlled bytes before fontsNeedUpdate’s virtual dispatch; save() count and font-load timing are script-controlled, aiding grooming, but reclaim/vtable control is not demonstrated by the diff.
  5. Scope — Renderer-process corruption on a virtual-call path (fontsNeedUpdate), a favorable target for control-flow hijack if reclaim succeeds.

Detection & hunting

For defenders and detection engineers:

  • fontsNeedUpdate on freed FontProxy —
  • Canvas save() growth + font events —
  • FontSelector client set stale entries —

Remaining risk in the patch

  • fontsNeedUpdate/update bodies not shown — The diff does not include FontProxy::update()/fontsNeedUpdate() or FontSelector’s client storage, so that the stable heap address fully closes the registration/deregistration race is (inferred) from the structural change.
  • clone semantics — State::clone() deep-copies font via clone() (a fresh registered client) but the FIXME notes copy-on-write is not implemented, so every save clones/registers eagerly; any path that still value-copies State (outside clone) would reintroduce the aliasing, relying on the now-private copy ctor to prevent it.
  • Registration timing — A cloned FontProxy created via clone() copies state; whether clone re-registers with the FontSelector or defers until realized()/initialize() is not shown, leaving the exact registration lifecycle of cloned states unverified.

Audit directions

  • Other FontSelectorClients — Audit all FontSelectorClient subclasses for by-value storage inside copyable/relocating containers that would move a registered client’s address.
  • By-value members in Vector<T,N> — Look for other self-registering objects stored as inline members of State-like structs kept in inline-capacity Vectors that relocate.
  • Canvas state copies — Verify every place State is duplicated uses clone() (not the now-private copy ctor) so no path recreates the dangling-client pattern.

Before / after

Loading diff…