← All reports

[4] WebGL: `readPixels` PBO offset reinterpreted as a host pointer

MediumWebCore WebGL backend — GraphicsContextGLANGLEOOB

A WebGL buffer offset that host-side code read as a base address.

d0000ca

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

void GraphicsContextGLANGLE::readPixelsBufferObject(IntRect rect, GCGLenum format, ...)
{
if (!makeContextCurrent())
return;
+
+ if (!m_isForWebGL2) {
+ addError(GCGLErrorCode::InvalidOperation);
+ return;
+ }
+
+ GCGLuint pixelPackBuffer = 0;
+ GL_GetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, reinterpret_cast<GCGLint*>(&pixelPackBuffer));
+ if (!pixelPackBuffer) {
+ addError(GCGLErrorCode::InvalidOperation);
+ return;
+ }
+
+ auto attrs = contextAttributes();
+ if (attrs.antialias && m_state.boundReadFBO == m_multisampleFBO) {
+ resolveMultisamplingIfNecessary(rect);
+ GL_BindFramebuffer(GraphicsContextGL::READ_FRAMEBUFFER, m_fbo);
+ }
+
setPackParameters(alignment, rowLength, false);
- GLsizei bufferSize = 0;
- GL_GetBufferParameterivRobustANGLE(GL_PIXEL_PACK_BUFFER, GL_BUFFER_SIZE, 1, nullptr, &bufferSize);
- // FIXME: Remove redundant use of unsafe std::span by calling GL_ReadPixelsRobustANGLE directly.
-WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN
- std::span<uint8_t> data(reinterpret_cast<uint8_t*>(offset), static_cast<size_t>(bufferSize));
-WTF_ALLOW_UNSAFE_BUFFER_USAGE_END
- readPixelsImpl(rect, format, type, data);
+
+ // ANGLE validates the read size against the PBO size.
+ GLsizei bufferSize = std::numeric_limits<GLsizei>::max();
+
+ GL_ReadPixelsRobustANGLE(rect.x(), rect.y(), rect.width(), rect.height(), format, type, bufferSize, nullptr, nullptr, nullptr, reinterpret_cast<void*>(offset));
+
+ if (attrs.antialias && m_state.boundReadFBO == m_multisampleFBO)
+ GL_BindFramebuffer(GraphicsContextGL::READ_FRAMEBUFFER, m_multisampleFBO);
}

LayoutTests/webgl/readpixels-pbo-offset-validation.html

+function runTest(contextOptions) {
+ var gl = wtu.create3DContext(canvas, contextOptions, 2);
+ var pbo = gl.createBuffer();
+ gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbo);
+ var bufferSize = 64 * 64 * 4;
+ gl.bufferData(gl.PIXEL_PACK_BUFFER, bufferSize, gl.DYNAMIC_READ);
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
+
+ gl.readPixels(0, 0, 32, 32, gl.RGBA, gl.UNSIGNED_BYTE, 256); // offset != 0, previously hit the client-buffer path
+ wtu.glErrorShouldBe(gl, gl.NO_ERROR, "32x32, offset=256");
+ gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, bufferSize - 1);
+ wtu.glErrorShouldBe(gl, gl.INVALID_OPERATION, "offset==bufferSize - 1");
+ gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, 0x7FFFFFFF);
+ wtu.glErrorShouldBe(gl, gl.INVALID_OPERATION, "offset==0x7FFFFFFF");
+}
+for (let alpha of [true, false]) {
+ for (let antialias of [true, false])
+ runTest({alpha, antialias});
+}

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.

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.

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.