[4] WebGL: `readPixels` PBO offset reinterpreted as a host pointer
A WebGL buffer offset that host-side code read as a base address.
Medium. One integer served two addressing conventions: a GPU-buffer byte offset that a shared host-memory helper consumed as a base address. The reachable addresses appear bounded by a PBO the page can actually get allocated, so the realistic outcome is a deterministic graphics-process crash rather than a shaped write.
Graphics APIs overload one parameter across two address spaces: glReadPixels takes a void* destination that is a genuine host pointer when no pixel pack buffer is bound and a byte offset into GPU-side storage when one is. WebKit's ANGLE-backed GraphicsContextGLANGLE — the object every WebGL context issues its GL commands through, running in the GPU process where GPU-process WebGL is enabled — routed both forms of readPixels through one shared helper that takes a std::span<uint8_t>, a pointer-plus-length pair with no ownership and no validation. The expectation is that a value crossing the WebGL API boundary as a buffer offset stays inside GL-side address arithmetic that ANGLE bounds-checks, and never becomes a host pointer in WebKit's own code.
The angle: a page calling WebGL2 readPixels into a pixel pack buffer at a chosen offset selects the numeric base address that host-side pixel post-processing writes to in the graphics process.
Avoid using the client buffer path when reading pixels to PBO. The legacy wipeAlphaChannelFromPixels would be run if PBO was read to with an offset.
Source/WebCore/platform/graphics/angle/GraphicsContextGLANGLE.cpp
LayoutTests/webgl/readpixels-pbo-offset-validation.html
Patch Details
The rewrite of GraphicsContextGLANGLE::readPixelsBufferObject() spans three clusters: two new entry guards that establish the function's preconditions locally, an inlining of the multisample handling that the deleted helper used to perform, and the replacement of the span-based client-buffer call with a direct robust GL read.
The guards are a !m_isForWebGL2 check and a GL_GetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, ...) check, each rejecting with addError(GCGLErrorCode::InvalidOperation) — so the function now refuses to run unless it is a WebGL2 context with a pixel pack buffer actually bound.
The multisample handling is resolveMultisamplingIfNecessary(rect) plus GL_BindFramebuffer(READ_FRAMEBUFFER, m_fbo) before the read and a rebind back to m_multisampleFBO afterwards, both gated on attrs.antialias && m_state.boundReadFBO == m_multisampleFBO.
The core change deletes the construction of std::span<uint8_t> data(reinterpret_cast<uint8_t*>(offset), bufferSize) — a span whose data pointer is the PBO byte offset cast to a host pointer — together with the GL_GetBufferParameterivRobustANGLE(GL_PIXEL_PACK_BUFFER, GL_BUFFER_SIZE, ...) query, the WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN/END block, and its FIXME. The read is issued directly as GL_ReadPixelsRobustANGLE(..., bufferSize = std::numeric_limits<GLsizei>::max(), nullptr, nullptr, nullptr, reinterpret_cast<void*>(offset)), with the comment // ANGLE validates the read size against the PBO size. — the robust-size argument is deliberately made non-binding and PBO bounds enforcement is delegated to ANGLE. setPackParameters(alignment, rowLength, false) is retained. The new layout test exercises readPixels into a PBO across the four {alpha, antialias} context-attribute combinations with offsets 0, 256, -1, 4, bufferSize, bufferSize-1, 0x7FFFFFFF, and 0x7FFFFFFD, asserting INVALID_VALUE for the negative offset and INVALID_OPERATION for every offset whose read would not fit.
Reusing one integer under two incompatible addressing conventions - a buffer-relative offset consumed downstream as an absolute host pointer.
Background
Where this lives.
WebGL contexts issue their GL commands through GraphicsContextGL; GraphicsContextGLANGLE is the ANGLE-backed implementation. With GPU-process WebGL, that object executes in the GPU process behind RemoteGraphicsContextGL, driven over IPC by RemoteGraphicsContextGLProxy in the WebContent process. That process split is why the implementation is expected to validate its own inputs: a compromised WebContent process can send arbitrary IPC messages, which is what MESSAGE_CHECK exists for.
Pixel Buffer Objects.
A PBO is a GL buffer object bound to GL_PIXEL_PACK_BUFFER that can serve as the destination of a pixel read, keeping the data in GPU-side storage instead of copying it to CPU memory. WebGL2 exposes this via gl.bindBuffer(gl.PIXEL_PACK_BUFFER, buf).
The GL pointer/offset convention.
Entry points such as glReadPixels take a void* destination. When a pixel pack buffer is bound, that argument is not a pointer at all — it is a byte offset into the bound buffer, conventionally passed by casting a small integer to void*. The same C parameter therefore means two entirely different things depending on the current binding state. In WebIDL, the offset overload of WebGL2RenderingContext.readPixels takes a GLintptr, a 64-bit integer supplied directly by script.
Robust ANGLE entry points.
GL_ReadPixelsRobustANGLE and friends take an extra bufferSize argument describing how many bytes the destination can hold, so ANGLE can reject reads that would not fit. Passing std::numeric_limits<GLsizei>::max() makes that particular check non-binding and leaves ANGLE's own PBO-size validation as the operative bound.
Alpha-channel wipe.
A WebGL context created with alpha: false must present an opaque drawing buffer, so WebKit post-processes read-back pixel data with wipeAlphaChannelFromPixels(), overwriting each pixel's alpha byte in the CPU-side buffer. This is a host-memory operation over a std::span<uint8_t>.
Multisample resolve.
For antialias: true contexts, rendering goes to m_multisampleFBO; before pixels can be read back, resolveMultisamplingIfNecessary() blits into the single-sample m_fbo, which then has to be bound as the read framebuffer for the duration of the read.
std::span<uint8_t>.
A pointer-plus-length pair with no ownership and no validation — constructing one asserts, rather than checks, that length bytes are readable and writable at pointer. WebKit marks call sites that build spans from raw pointers with WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN/END.
Analysis
The bug is a pointer/offset type confusion: a buffer-relative offset carried into a code path that treats its span base as a host address.
Before: After:
readPixelsBufferObject(offset) readPixelsBufferObject(offset)
└─ query PBO GL_BUFFER_SIZE ├─ require m_isForWebGL2
└─ span{ (uint8_t*)offset, size } ├─ require PIXEL_PACK_BUFFER_BINDING
│ base = script-chosen int ├─ resolveMultisamplingIfNecessary
▼ └─ GL_ReadPixelsRobustANGLE(
readPixelsImpl(span) ..., (void*)offset)
├─ GL read ──► lands in PBO (ok) └─ offset stays an offset;
└─ host fixup on span.data() no host span exists
└─ writes at absolute addr = offset
The pre-fix path manufactured a std::span<uint8_t> out of two values that do not describe a host buffer: the data pointer was reinterpret_cast<uint8_t*>(offset) — the WebGL-supplied byte offset into the pixel pack buffer — and the length was the PBO's GL_BUFFER_SIZE. That span was handed to readPixelsImpl(), the same helper used for the client-memory ArrayBufferView form. For the GL call itself this is benign, because with a pack buffer bound the pointer argument is an offset by convention, so the underlying robust read interprets it correctly and writes into the PBO. The invariant that was missing is that the span must not be treated as host memory by anything else on that path. Any post-transfer host-side fixup the helper performs on the caller-supplied span — the opaque-alpha fixup for alpha: false contexts, which the commit message's reference to the legacy path points at — would write into memory at an absolute address numerically equal to a script-chosen buffer offset. The supplied source excerpt is truncated before readPixelsImpl(), so the presence and exact shape of that fixup is inferred from the diff's removal of the call plus general WebKit knowledge; the entire host-memory-write mechanism rests on it. Nothing on the pre-fix path mapped the offset back to the PBO's real backing store, and nothing checked that the destination was host-addressable at all — for offset == 0 the span's data pointer is null.
The two new guards are best read as precondition hardening and error-code correctness rather than as closing a second memory-safety hole. The pre-fix code initialised GLsizei bufferSize = 0 and filled it from the buffer-parameter query; with no pack buffer bound that query fails and leaves bufferSize == 0, so the span had zero length and the robust size argument reaching ANGLE was 0 — the read would have been rejected rather than writing anywhere. The guards make the failure explicit as a spec-correct INVALID_OPERATION and re-establish locally the preconditions the pointer arithmetic depends on, which matters for a function that also executes in the GPU process.
Reachability is direct from web content: WebGL2RenderingContext::readPixels(x, y, w, h, format, type, GLintptr offset) is the caller shape that reaches readPixelsBufferObject, and the new layout test drives the whole sequence from JavaScript. Following the test: create a WebGL2 context with {alpha: false} (the attribute that would arm the opaque-alpha post-processing on the shared path), then gl.createBuffer() + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbo) + gl.bufferData(gl.PIXEL_PACK_BUFFER, bufferSize, gl.DYNAMIC_READ) to establish a large pack buffer, clear the drawing buffer, and call gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, offset) with a non-zero offset. Pre-fix, the GL read itself landed in the PBO, but any host-side fixup inside readPixelsImpl would use data.data() — the offset — as its base address. The test's offset = 256 case with a 64 * 64 * 4 PBO is exactly this shape; its post-fix expectations (NO_ERROR, and getBufferSubData showing expectedNoAlpha at 256 and zeros at 0) confirm the read now lands only inside the buffer.
The corruption shape on the inferred alpha-wipe path is a strided single-byte write starting at absolute address offset, with extent governed by the read rectangle. Address selection is bounded by ANGLE's validation of the same offset during the GL read that precedes the fixup — the write only follows a read ANGLE accepted, so offset has to be smaller than a PBO the page managed to allocate, and GL_BUFFER_SIZE is queried as a GLsizei. Offsets in the low address range would fault on the first byte written on platforms that reserve the null page. The immediate observable effect is therefore an attacker-triggered crash of the graphics process, matching the bug title.
Anchored to the deleted span construction, the best-case attacker transition is conditional: if a page could get offset accepted at a value that lands in mapped memory — which would require ANGLE to have validated a correspondingly large pixel pack buffer allocation — the same post-transfer loop could turn into a strided fixed-value write into heap data or allocator metadata in the graphics process, and under controlled heap grooming that might amount to a corruption primitive rather than a fault. The value written is not attacker-controlled, and the loop bounds are not visible in the supplied context, so the quality of that primitive remains a projection. Note what does not hold: reading the missing GL_PIXEL_PACK_BUFFER_BINDING guard as a second write primitive — ANGLE treating offset as a genuine client destination pointer with no PBO bound — is contradicted by the pre-fix code, since GLsizei bufferSize = 0 combined with a failing size query would have left the robust size at 0 and the read rejected.
This vulnerability weakens memory safety in the process hosting the WebGL backend — the GPU process where GPU-process WebGL is enabled, otherwise WebContent. The security model assumption it breaks is that values crossing the WebGL API boundary as buffer offsets stay confined to GL-side address arithmetic that ANGLE bounds-checks, and never become host pointers in WebKit's own code. Before the fix, a page that chose the readPixels PBO offset thereby chose the numeric base address seen by any host-side post-processing on the shared client-memory path. Corrupting the GPU process would not by itself constitute a sandbox escape, but the GPU process is a common second-stage target because it is more privileged with respect to graphics and media resources than WebContent.
The FIXME the patch deletes — // FIXME: Remove redundant use of unsafe std::span by calling GL_ReadPixelsRobustANGLE directly. — was already flagging the exact construct that was unsafe, but framed it as redundancy rather than as a correctness hazard. The hazardous part was never the span's redundancy; it was that the span's base carried a different addressing convention from every other span in the file. WebKit's WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN/END markers are a good inventory of exactly these sites, and this one shows the failure mode they exist to catch: a span built from a value that is not a host pointer at all, which becomes indistinguishable from a legitimate buffer once it is passed one function deeper.
Audit directions
-
An integer whose meaning depends on external binding state, carried through a shared helper that cannot tell the two apart. The invariant is that a value whose meaning depends on external state must be re-tagged, or the state re-asserted, at every boundary it crosses — never widened into a generic pointer or span. Narrow: grep
Source/WebCore/platform/graphics/angle/forreinterpret_cast<uint8_t*>(offset),reinterpret_cast<void*>(offset), and theasPointers()helper inGraphicsContextGLANGLE.cpp, and check each site for a downstream host-memory touch; the tell is the cast result flowing into anything other than an immediateGL_*call argument. Wider: the same class appears wherever an API overloads one parameter across two address spaces —drawElements/vertexAttribPointerindex and attribute offsets,getBufferSubData,texImage*/texSubImage*andcompressedTexImage*unpack-buffer variants, anybufferSubDatapath; the shape to notice is a function acceptingGCGLintptr/GLintptrthat later constructs astd::spanor does pointer arithmetic on it. Widest: this is the general handle-interpreted-as-address class, holding wherever a descriptor, index, or file offset is smuggled through avoid*-typed parameter — Vulkan/Metal buffer offsets,mmapoffsets, io_uringuser_datafields, FFI bindings passing integers as opaque pointers. If a parameter's type isvoid*but its meaning is "offset" in some states, every consumer downstream of the first cast must be audited for dereference, because the type system has stopped helping. -
A shared helper reached by two callers with different memory models. The danger is that the helper looks correct in isolation and correct for the majority caller, and the minority caller inherits a step never meant for it. Narrow: in
GraphicsContextGLANGLE.cpp, enumerate the remaining callers ofreadPixelsImpl()and of any other*Impl()helper that both issues aGL_*RobustANGLEcall and touches the passed span afterwards; the tell is any statement after the GL call that indexes into the caller-supplied span — alpha wipes, endian swaps, row flips, premultiply/unpremultiply passes. Wider: the same shape occurs anywhere a CPU-side fixup is applied to data that may actually live in GPU or shared memory — inspect thetexImage/readPixelspixel-format conversion helpers and theImageBuffer/PixelBufferconversion paths for post-transfer fixups applied unconditionally. Widest: a routine that performs both a transfer and an in-place fixup must be parameterised by where the data landed, not by where the transfer request came from — this applies to any DMA or zero-copy design (io_uring, GPU staging buffers, shared-memory IPC ring buffers) where a legacy in-place post-processing step predates the zero-copy path. Match tell: a function whose signature cannot express the destination's residency, called from both a copy path and a zero-copy path. -
Preconditions enforced only at the API-validation layer while the implementation layer also serves an IPC endpoint. Any function executing in a service process on behalf of lower-privileged callers should re-establish, locally, every state precondition its pointer arithmetic depends on — even where an incidental property, here a zero-initialised size query, happens to fail safe today. Narrow: for each
GraphicsContextGLANGLEmethod exposed throughSource/WebKit/GPUProcess/graphics/RemoteGraphicsContextGL.cppand its.messages.in, check whether the method's correctness depends on GL binding state (*_BUFFER_BINDING, bound FBO, current program, context version) and whether it queries or asserts that state; the tell added by this patch is theGL_GetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, ...)guard plus them_isForWebGL2check, and its absence elsewhere is the candidate. Wider: the same asymmetry shows up for any service-process handler whose safety argument lives in the client — auditRemoteRenderingBackend,RemoteMediaPlayer, and WebGPU'sRemoteBuffer/RemoteDevicehandlers for parameters validated only by the WebCore-side wrapper, looking for handlers that use a size or offset without aMESSAGE_CHECKon it. Widest: validation performed on the caller's side of a privilege boundary is advisory, not enforcing — it applies to Chromium's Mojo handlers, kernel syscall argument checking, and any RPC service reusing a library written for in-process use. Match tell: an implementation function whose comments or structure imply "the caller already checked this" while one of its callers is a deserialiser. -
WebGL2 paths where a context attribute changes the post-read work rather than the read itself, since the suspected arming condition here was
alpha: falseand the new test only exercises it by sweeping{alpha, antialias}combinations. Trace whichGraphicsContextGLANGLEoperations branch oncontextAttributes().alpha,.antialias,.premultipliedAlpha, or.preserveDrawingBufferafter data has already been transferred, and confirm each branch is valid for both the client-memory and PBO destinations. Match tell: a post-transfer branch keyed on a context attribute with no corresponding branch on the destination kind — existing tests that only cover the default attribute set will not exercise it, so coverage gaps inLayoutTests/webgl/for non-default context attributes are themselves a signal of where to look.