← WebKit — 2026-W41

2522677a2f56ebcae8a68d548f882ca6b15b9d78  WebGL: Answer the default framebuffer queries in the WebGLRenderingContext level

severity medium class OOB-write confidence 0.6 signal score 12.47 Source/WebCore/html patch-now AI review CONFIRMED
Primitive: WebGL default drawing-buffer/canvas size not clamped to real GL/compositor (IOSurface) limits
Triage note: GraphicsContextGLANGLE::initialize and GraphicsContextGLCocoa::platformInitialize changed clampToMinimumSize to clampToMaximumSize for m_maxInternalFramebufferSize, and clampedCanvasSize() now clamps to m_maxDrawingBufferSize; the old min-clamp let the effective max drawing-buffer grow to the LARGER of texture/renderbuffer/IOSurface sizes, allowing a web page to request an oversized default framebuffer beyond the compositor's backing limits.
Structural signals: smartptr_member_added (+3.0), null_guard (+2.59), clamp_saturate (+3.88), msg_soft (+1.0), area:Source/WebCore/html (+2.0)

The bug at a glance

A WebGL size-clamping logic error in the GPU process: the “max internal framebuffer size” was computed as the LARGER of the viewport, texture, renderbuffer and (Cocoa) IOSurface limits via clampToMinimumSize, and clampedCanvasSize() never clamped to the compositor’s drawing-buffer limit, so a web page could request a default drawing buffer larger than the compositor’s IOSurface backing can hold. The reachable surface is WebGL from ordinary web content (set canvas width/height, create a context); the mis-sized buffer is allocated in the GPU process (GraphicsContextGLANGLE/GraphicsContextGLCocoa). Triage calls this OOB-write/medium; from the diff the proven defect is the size-bound logic error and the resulting over-sized/mismatched allocation, while an actual out-of-bounds write against compositor backing is not demonstrated and is marked (inferred). A GPU-process crash or allocation failure is the floor; memory corruption is the ceiling and is unproven here.

A single inverted clamp direction (clampToMinimumSize vs clampToMaximumSize) turns a “take the smallest of all limits” into “take the largest”, so the hardware/compositor ceiling that was meant to cap the drawing buffer instead raised it.

Root cause

The invariant is that the default drawing buffer must be no larger than the SMALLEST of every relevant limit: GL MAX_VIEWPORT_DIMS, MAX_TEXTURE_SIZE, MAX_RENDERBUFFER_SIZE and, on Cocoa, IOSurface::maximumSize() (the compositor backing). In GraphicsContextGLANGLE::initialize the code seeded m_maxInternalFramebufferSize from maxViewportDims and then called clampToMinimumSize({maxTextureSize,…}) and clampToMinimumSize({maxRenderbufferSize,…}); IntSize::clampToMinimumSize raises each dimension to max(current, arg), so the result became the MAXIMUM of the limits, not the minimum. GraphicsContextGLCocoa::platformInitialize compounded this with clampToMinimumSize(IOSurface::maximumSize()), raising the cap toward the IOSurface max rather than lowering it.

The second half of the invariant lived in WebGLRenderingContextBase::clampedCanvasSize(): it clamped the canvas to std::min(m_maxTextureSize, m_maxRenderbufferSize) and to m_maxViewportDims, but there was no notion of a separate compositor/drawing-buffer limit, so a canvas sized up to the texture/renderbuffer ceiling could drive a default framebuffer the compositor cannot back. Pre-patch sequence: 1. page sets canvas.width/height very large and gets a webgl/webgl2 context; 2. clampedCanvasSize() clamps only to texture/renderbuffer/viewport dims; 3. WebGLDefaultFramebuffer::reshape() allocates storage at that size in the GPU process; 4. because m_maxInternalFramebufferSize was the max (not min) of the limits, and IOSurface::maximumSize() was applied as a floor, the effective drawing buffer can exceed the compositor’s IOSurface backing limit.

The GraphicsContextGL objects are RefCounted and run in the GPU process; the limits are plumbed to the web process over IPC (RemoteGraphicsContextGLInitializationState). This is a value/bounds-propagation flaw, not a lifetime bug — nothing is freed. The fix therefore does not touch object lifetime; it corrects the clamp direction and introduces a distinct maxDrawingBufferSize that is itself clamped down and additionally applied in clampedCanvasSize(). (inferred) escalation beyond a GPU-process allocation/DoS would require showing a write past the smaller compositor buffer, which the diff does not establish.

Key code

Inverted clamp: max-of-limits became min-of-limits (GraphicsContextGLANGLE::initialize)

m_maxInternalFramebufferSize = { maxViewportDims[0], maxViewportDims[1] };
-    m_maxInternalFramebufferSize.clampToMinimumSize({ maxTextureSize, maxTextureSize });
-    m_maxInternalFramebufferSize.clampToMinimumSize({ maxRenderbufferSize, maxRenderbufferSize });
+    m_maxInternalFramebufferSize.clampToMaximumSize({ maxTextureSize, maxTextureSize });
+    m_maxInternalFramebufferSize.clampToMaximumSize({ maxRenderbufferSize, maxRenderbufferSize });
// Cocoa: m_maxInternalFramebufferSize.clampToMaximumSize(IOSurface::maximumSize());
// clampedCanvasSize(): canvasSize.clampToMaximumSize({ m_maxDrawingBufferSize[0], m_maxDrawingBufferSize[1] });

Fix approach

The patch flips both ANGLE clamps and the Cocoa IOSurface clamp to clampToMaximumSize so m_maxInternalFramebufferSize collapses to min(viewport, texture, renderbuffer, IOSurface-max) as intended. It then exposes that value as a first-class GraphicsContextGL::maxDrawingBufferSize(), plumbs it through the GPU→web-process initialization state (RemoteGraphicsContextGLInitializationState) into m_maxDrawingBufferSize, and adds a second clampToMaximumSize({m_maxDrawingBufferSize[0], m_maxDrawingBufferSize[1]}) in clampedCanvasSize() so the canvas can never exceed the compositor’s backing limit. A semantic side change ships in the same commit: default-framebuffer getParameter queries (DEPTH_BITS, STENCIL_BITS, SAMPLES, RED/GREEN/BLUE/ALPHA_BITS, SAMPLE_BUFFERS) now answer from WebGLDefaultFramebuffer’s chosen formats (depthBits()/stencilBits()/sampleCount()) and checkFramebufferStatus short-circuits to COMPLETE for the default framebuffer, instead of round-tripping to the GL context.

Patch walkthrough

  • Source/WebCore/platform/graphics/angle/GraphicsContextGLANGLE.cpp — Corrects the two clampToMinimumSize calls to clampToMaximumSize so the internal framebuffer size is the minimum of the GL limits, and implements maxDrawingBufferSize() returning that clamped value.
  • Source/WebCore/platform/graphics/cocoa/GraphicsContextGLCocoa.mm — Flips the IOSurface::maximumSize() clamp from minimum to maximum so the compositor backing limit lowers (not raises) the cap.
  • Source/WebCore/html/canvas/WebGLRenderingContextBase.cpp — Caches m_maxDrawingBufferSize at init and adds a clampToMaximumSize to clampedCanvasSize(); also rewrites default-framebuffer parameter queries to use format-derived values.
  • Source/WebCore/html/canvas/WebGLDefaultFramebuffer.cpp/.h — Stores m_sampleCount at creation and adds depthBits()/stencilBits()/sampleCount() accessors so queries do not depend on allocated storage.
  • Source/WebCore/platform/graphics/GraphicsContextGL.h + GPUProcess/WebProcess Remote* files — Adds the virtual maxDrawingBufferSize() and plumbs it across the GPU-process/web-process serialization boundary.
  • Source/WebCore/testing/Internals.* — Adds webglMaxDrawingBufferSize() test hook to assert the new clamped limit.

Background

Default drawing buffer — The framebuffer WebGL draws into when no FBO is bound (framebuffer 0); its pixels are handed to the compositor, so its size is bounded both by GL object limits and by the compositor’s backing-store limits.

IOSurface — The Cocoa shared GPU buffer used to hand WebGL output to the compositor; IOSurface::maximumSize() is the largest surface the compositor can back, and the drawing buffer must not exceed it.

clampToMinimumSize vs clampToMaximumSize — IntSize helpers: clampToMinimumSize raises each dimension to at least the argument (max), clampToMaximumSize lowers it to at most the argument (min). Choosing the wrong one inverts a lower-bound into an upper-bound.

GraphicsContextGLANGLE / GPU process — The ANGLE-backed GL implementation; in modern WebKit it runs in the GPU process, and its reported limits are serialized to the web process via RemoteGraphicsContextGLInitializationState.

clampedCanvasSize() — WebGLRenderingContextBase routine that reduces the author-requested canvas size to safe GL limits before the backing buffer is reshaped.

Vulnerability window

  1. Setup — Page creates a canvas and requests a webgl2 context, optionally with antialias to force a multisampled default framebuffer.
  2. Trigger — Page sets canvas.width/height to a very large value (up to the texture/renderbuffer ceiling).
  3. Clamp — clampedCanvasSize() clamps only to texture/renderbuffer/viewport limits (pre-patch), not the compositor limit.
  4. Allocate — WebGLDefaultFramebuffer::reshape allocates GPU-process storage at a size that can exceed IOSurface::maximumSize().
  5. Fault — Allocation at a size beyond compositor backing — GPU-process crash/allocation failure, with memory corruption only (inferred).

Proof of concept

No regression test or PoC is shipped (the diff only adds an Internals.webglMaxDrawingBufferSize() assertion hook). Conceptual trigger: const c=document.createElement(‘canvas’); c.width=c.height=16384; const gl=c.getContext(‘webgl2’,{antialias:true}); then force a reshape/draw; on a build where IOSurface::maximumSize() is smaller than MAX_TEXTURE_SIZE the pre-patch code backs the default framebuffer beyond the compositor limit. Any OOB-write escalation is (inferred).

Exploitation

  1. Reach — Ordinary web content: a canvas + getContext(‘webgl’|‘webgl2’) call, no special privilege.
  2. Trigger — Oversize the canvas so clampedCanvasSize() yields a size above the compositor’s IOSurface limit (requires MAX_TEXTURE_SIZE/MAX_RENDERBUFFER_SIZE > IOSurface max on the target GPU).
  3. Primitive — Mismatched/oversized drawing-buffer allocation in the GPU process; primary observable is a controlled-size allocation that exceeds compositor backing.
  4. Escalation (inferred) — Whether the over-size can be steered into an out-of-bounds write against the smaller compositor surface, and whether the size is attacker-controllable finely enough to land useful adjacent data, both gate anything past a GPU-process DoS.

Detection & hunting

For defenders and detection engineers:

  • Canvas/drawing-buffer dimensions exceeding compositor limits — Instrument WebGLDefaultFramebuffer::reshape / IOSurface allocation to log sizes that exceed IOSurface::maximumSize() on the running device.
  • GPU-process allocation failures or crashes tied to WebGL reshape — Correlate GPU-process crash reports with large canvas sizes from a single web origin.
  • Regression assertion drift — Use Internals.webglMaxDrawingBufferSize() in tests to assert the reported max is <= each GL limit and the IOSurface max.

Remaining risk in the patch

  • OOB-write is unproven by the diff — The commit proves a size-bound logic error; it does not show that an over-sized default framebuffer actually writes past the compositor’s smaller backing, so the OOB-write triage severity is (inferred) and may overstate a GPU-process DoS.
  • Device-dependent reachability — The bug only bites where a GL limit exceeds IOSurface::maximumSize(); on GPUs where the GL limits are already the smallest, the pre-patch max-of-limits coincided with the correct value and nothing was over-sized.

Audit directions

  • clampToMinimumSize/clampToMaximumSize call sites — Grep all IntSize/FloatSize clamp usages for inverted direction where an upper bound was intended, especially around GPU buffer sizing.
  • Other GPU-backed buffers (WebGPU, 2D canvas, OffscreenCanvas, WebXR) — Check that their size clamps also apply the compositor/IOSurface limit, not just GL texture limits.
  • GPU→web-process limit plumbing — Audit RemoteGraphicsContextGLInitializationState fields for any limit used for allocation decisions in the web process that could be stale or larger than the GPU-process truth.

Before / after

Loading diff…