← WebKit Silent-Fix Report — 2026-W25

d0000cab59  WebGL: Crash when reading pixels to PBO with an offset

severity high class OOB confidence 0.72 WebCore WebGL exploitable-grade
Kimmo Kinnunen Tue Jun 16 13:58:26 2026 -0700 full: d0000cab59a2bb2ae9dbb8afa66920ff2c2f59ff bug report ↗ view on GitHub ↗
Primitive: OOB via unvalidated readPixels PBO offset
Triage note: Missing bounds validation of a script-controlled PBO offset in readPixels led to an out-of-bounds access/crash.
Contents

The bug at a glance

This fixes an out-of-bounds access in WebGL2 readPixels into a PIXEL_PACK_BUFFER (PBO) with a byte offset, reachable directly from unprivileged page JavaScript. The pre-patch code fabricated a client-buffer span of size bufferSize starting at the raw offset value and drove the legacy wipeAlphaChannelFromPixels path, so a script-controlled offset produced an out-of-bounds write/read relative to the PBO. Script-reachable graphics OOB in the GPU/graphics path is a serious memory-safety issue, warranting high; the public artifact shows validation/crash-prevention rather than a full exploit.

The angle is that the PBO offset is an offset into a GPU buffer, but the old code reinterpreted it as (and combined it with bufferSize into) a CPU-side std::span used by the client-buffer readback path. Any nonzero or oversized offset therefore addressed memory relative to a bogus base, and the fix routes PBO reads through GL_ReadPixelsRobustANGLE where ANGLE validates the read size against the actual PBO size and emits INVALID_OPERATION/INVALID_VALUE instead.

Root cause

OBSERVED: GraphicsContextGLANGLE::readPixelsBufferObject (the WebGL readPixels path when a PIXEL_PACK_BUFFER is bound and an offset is given) previously did: query bufferSize via GL_GetBufferParameterivRobustANGLE, then construct std::span<uint8_t> data(reinterpret_cast<uint8_t*>(offset), static_cast<size_t>(bufferSize)) and call readPixelsImpl(rect, format, type, data). That is, it treated the GPU-buffer byte offset as a pointer and paired it with the full buffer size to form a client-side span, driving the same code as an ordinary client-buffer readback (including the legacy wipeAlphaChannelFromPixels post-processing).

INFERRED: The offset is entirely script-controlled (the readPixels last argument). Building a span at reinterpret_cast<uint8_t*>(offset) with length bufferSize means the readback/alpha-wipe logic operates on a region [offset, offset+bufferSize) interpreted as memory, so any offset != 0 shifts the base and any large offset points far outside the real PBO allocation, producing out-of-bounds reads/writes (the commit title says ‘Crash when reading pixels to PBO with an offset’). The wipeAlphaChannelFromPixels legacy path, meant for CPU buffers, would run over this bogus span.

OBSERVED: The fix removes the client-buffer span entirely. It first rejects non-WebGL2 contexts (addError InvalidOperation) and rejects the case where no PIXEL_PACK_BUFFER is bound (GL_GetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING); if zero, InvalidOperation). It handles multisample resolve (resolveMultisamplingIfNecessary and rebinding the read FBO) for antialiased contexts. It then calls GL_ReadPixelsRobustANGLE(rect…, format, type, bufferSize=std::numeric_limits<GLsizei>::max(), nullptr, nullptr, nullptr, reinterpret_cast<void*>(offset)), passing the offset as the PBO destination pointer and letting ANGLE validate the actual read size against the bound PBO’s size.

OBSERVED: The regression test confirms the new validation: offset==0 and offset==256 within a 64644 buffer succeed (NO_ERROR); offset=-1 gives INVALID_VALUE; offset==4, ==bufferSize, ==bufferSize-1, ==0x7FFFFFFF, ==0x7FFFFFFD all give INVALID_OPERATION, across alpha/antialias combinations.

Key code

Old client-buffer span vs new robust PBO read (GraphicsContextGLANGLE.cpp)

    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);

    // 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);

Patch walkthrough

  • Source/WebCore/platform/graphics/angle/GraphicsContextGLANGLE.cpp — readPixelsBufferObject is rewritten. It now (1) requires a WebGL2 context, (2) requires a nonzero GL_PIXEL_PACK_BUFFER_BINDING, (3) resolves multisampling and rebinds the read FBO for antialiased contexts, and (4) calls GL_ReadPixelsRobustANGLE with the script offset as the destination pointer and GLsizei max as the nominal size so ANGLE performs the real bounds check against the PBO, replacing the old code that built a std::span at reinterpret_cast<uint8_t*>(offset) of length bufferSize and ran it through readPixelsImpl / the legacy wipeAlphaChannelFromPixels client-buffer path.
  • LayoutTests/webgl/readpixels-pbo-offset-validation.html — New test exercising readPixels into a PBO across alpha and antialias context options with valid offsets (0, 256) and invalid ones (-1, 4, bufferSize, bufferSize-1, 0x7FFFFFFF, 0x7FFFFFFD), asserting NO_ERROR vs INVALID_VALUE/INVALID_OPERATION and no crash, verifying getBufferSubData contents for the valid cases.

Background

Pixel Pack Buffer (PBO) — In WebGL2, readPixels can target a buffer bound to PIXEL_PACK_BUFFER rather than a client ArrayBufferView. The last readPixels argument is then a byte offset into that GPU buffer, not a CPU pointer. Correct handling must treat it as a GPU-buffer offset and bounds-check against the buffer’s size, which is exactly what the old code failed to do.

GL_ReadPixelsRobustANGLE — ANGLE’s robust readPixels entry point takes an explicit destination buffer size and performs internal validation, returning GL errors (INVALID_OPERATION/INVALID_VALUE) instead of reading/writing out of bounds. Routing the PBO offset directly to this function lets ANGLE enforce that the requested read fits within the bound PBO.

wipeAlphaChannelFromPixels (legacy client path) — A CPU-side post-processing step for readback into client buffers (e.g. for non-alpha contexts). It assumes a valid CPU span; running it over a span fabricated from a raw PBO offset and full buffer size is what turned a script offset into an out-of-bounds memory operation.

Multisample resolve — Antialiased WebGL uses a multisample FBO that must be resolved to a single-sample FBO before pixels are read. The rewrite adds resolveMultisamplingIfNecessary and read-FBO rebinding for the antialias case, which the client-buffer path had handled elsewhere; the test covers antialias on/off to ensure both work.

GL error semantics — WebGL surfaces validation failures as GL errors rather than crashes. The fix converts previously crashing/OOB offsets into INVALID_VALUE (negative offset) and INVALID_OPERATION (offset too large, no PBO bound, or non-WebGL2), matching the WebGL2 spec and the test’s expectations.

Vulnerability window

  1. Implementation — readPixelsBufferObject builds a client-buffer std::span from the raw PBO offset and full buffer size, reusing the legacy client readback/alpha-wipe path.
  2. Defect — A script-controlled offset shifts the span base and can point outside the PBO allocation, and the alpha-wipe legacy path runs over that bogus region.
  3. Trigger — readPixels into a PBO with a nonzero/oversized offset causes an out-of-bounds access and crash (commit title).
  4. Report — Tracked as bugs.webkit.org 310333 / rdar://171685583; originally landed on a Safari branch (305413.554) and rdar://176061698 as a security fix.
  5. Fix — The client-buffer path is removed for PBO reads; the offset is passed to GL_ReadPixelsRobustANGLE which validates against the PBO size, with WebGL2/PBO-bound preconditions and multisample handling.
  6. Regression test — A layout test asserts correct GL errors for a matrix of invalid offsets and correct data for valid ones, with no crashes.

Proof of concept

The added layout test creates a WebGL2 context, binds a 64644-byte PBO, and calls readPixels with a range of offsets. The valid offsets (0, 256) must return NO_ERROR and correct data; the crafted offsets (-1, 4, bufferSize, bufferSize-1, 0x7FFFFFFF, 0x7FFFFFFD) must return INVALID_VALUE/INVALID_OPERATION without crashing. Pre-patch, a large offset such as 0x7FFFFFFF fed the client-buffer span path and caused the out-of-bounds access the commit title describes; the PoC demonstrates crash-prevention/validation, not a memory read primitive.

function runTest(contextOptions) {
    var canvas = document.createElement("canvas");
    var gl = wtu.create3DContext(canvas, contextOptions, 2);

    gl.clearColor(0.2, 0.4, 0.8, 0.8);
    gl.clear(gl.COLOR_BUFFER_BIT);

    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.bindBuffer(gl.COPY_READ_BUFFER, pbo);

    gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);

    gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, 0);
    wtu.glErrorShouldBe(gl, gl.NO_ERROR, "offset==0");

    gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, -1);
    wtu.glErrorShouldBe(gl, gl.INVALID_VALUE, "offset=-1");

    gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, 4);
    wtu.glErrorShouldBe(gl, gl.INVALID_OPERATION, "offset==4");

    gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, bufferSize);
    wtu.glErrorShouldBe(gl, gl.INVALID_OPERATION, "offset==bufferSize");

    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});
}

Exploitation

  1. Reachability — Directly reachable from any page that can obtain a WebGL2 context; readPixels into a PBO with an attacker-chosen offset requires no special privileges.
  2. Primitive (inferred) — Pre-patch, the fabricated std::span at reinterpret_cast<uint8_t*>(offset) of length bufferSize, driven through the legacy client readback/alpha-wipe path, implies an out-of-bounds memory access whose base is offset-controlled — a potential OOB read (and the alpha-wipe step writes into the span, suggesting OOB write) in the graphics process.
  3. Observed impact — The commit and test frame the outcome as a crash; the public artifact establishes crash/validation behavior, not a controlled read/write exploit.
  4. Honest caveat — Exact controllability of the base and length (and whether the access lands in the WebContent or GPU process for a given configuration) is not determinable from the patch alone; treat weaponization as plausible but unproven here.

Detection & hunting

For defenders and SOC / detection engineers:

  • Crashes in readPixelsBufferObject / wipeAlphaChannelFromPixels
  • readPixels PBO calls with nonzero/large offsets
  • Missing GL errors for out-of-range offsets

Audit directions

  • Other offset-as-pointer readback paths
  • Robust ANGLE entry-point coverage
  • Multisample/FBO rebinding correctness
  • GLsizei max sentinel usage

Before / after

Loading diff…