← WebKit Silent-Fix Report — 2026-W25

56c55ddc38  ANGLE: IndexRange integer overflow bypasses vertex index validation

severity high class IntOverflow confidence 0.85 ANGLE / WebGL exploitable-grade
Kimmo Kinnunen Tue Jun 16 14:01:25 2026 -0700 full: 56c55ddc3836f2d889530d66d5c452849e2c4b42 bug report ↗ view on GitHub ↗
Primitive: integer overflow bypasses vertex index validation -> OOB
Triage note: Message states an integer overflow in IndexRange defeats index validation, a classic WebGL OOB primitive.
Contents

The bug at a glance

ANGLE’s index-range computation feeds WebGL draw-call validation, which is reachable by any page using a WebGL context with no permission prompt. An integer overflow in gl::IndexRange let a draw whose largest index is 0xFFFFFFFF slip past the bounds check that rejects indices exceeding the bound vertex buffer, producing out-of-bounds vertex fetches in the GPU/driver path. OOB access driven by attacker-controlled index data in the GPU process warrants high severity; it is bounded by being an index/vertex read rather than an arbitrary write, so not critical.

A WebGL page uploads an index buffer containing the value 0xFFFFFFFF (or a full [0, 0xFFFFFFFF] range) and issues glDrawElements. ANGLE must compute the min/max index to verify every index lands inside the enabled vertex attribute buffers; the overflow made a maximal range look empty or tiny, so validation was skipped and out-of-range vertices were fetched.

Root cause

gl::IndexRange summarizes the indices referenced by a draw as an inclusive [start, end] span; ANGLE compares this against the sizes of the bound vertex attribute buffers to reject draws that would read a nonexistent vertex. The vulnerable struct stored the range as (mStart, mEnd, mCount) and derived the count in the constructor as mCount(static_cast<uint64_t>(end - start) + 1), treating mCount == 0 as the empty-range sentinel (isEmpty() const { return mCount == 0; }).

The defect is that the count representation cannot faithfully encode a full unsigned-int range. For a draw whose indices span the entire 32-bit space – start 0, end 0xFFFFFFFF – the true vertex count is 0x100000000, one more than fits in 32 bits. The subtraction end - start is performed in 32-bit unsigned arithmetic and yields 0xFFFFFFFF; historically the count field / downstream validation arithmetic operating in 32 bits wraps this maximal range to 0, which collides exactly with the empty-range sentinel. A range that should be flagged as “references index 0xFFFFFFFF” instead reports isEmpty()/vertexCount()==0, so the validator concludes there is nothing to bounds-check and lets the draw proceed. ANGLE then submits a draw that indexes vertices far outside the bound buffers.

The new WebGLCompatibilityTest cases pin the intended behavior: with an index buffer of {0, 0xFFFFFFFF} a glDrawElements(GL_LINES, 2, GL_UNSIGNED_INT, 0) must raise GL_INVALID_OPERATION (index 0xFFFFFFFF exceeds the tiny vertex buffer), while an in-range {0, 1} buffer must draw with no error. Pre-fix the maximal-index draw did not raise the error, evidencing the bypass.

The fix redefines IndexRange as pure (mStart, mEnd) with start > end denoting empty (default members mStart{1}, mEnd{0} make a default-constructed range empty), removes the stored mCount, and makes vertexCount() return a size_t computed as static_cast<size_t>(mEnd) - mStart + 1u so the full range yields 0x100000000 instead of wrapping. isEmpty() becomes mStart > mEnd, decoupling emptiness from an arithmetic-overflow sentinel, and operator== is defaulted on the two fields. With end preserved exactly and the count widened to size_t, the downstream comparison “is end within the vertex buffer” no longer aliases a maximal range onto the empty case.

Key code

Vulnerable vs fixed gl::IndexRange (mathutil.h)

// BEFORE (vulnerable):
//   IndexRange(uint32_t start, uint32_t end)
//       : mStart(start), mEnd(end), mCount(static_cast<uint64_t>(end - start) + 1)
//   { ASSERT(start <= end); }
//   bool isEmpty() const { return mCount == 0; }
//   uint64_t vertexCount() const { return mCount; }

// AFTER (fixed):
    IndexRange(uint32_t start, uint32_t end) : mStart(start), mEnd(end) { ASSERT(mStart <= mEnd); }
    bool isEmpty() const { return mStart > mEnd; }
    // Number of vertices in the range.
    // Range: [0, 0] == 1
    // Range: [0, 0xFFFFFFFF] == 0x100000000 (needs size_t).
    size_t vertexCount() const
    {
        // Note: unsigned underflow ok on isEmpty() == true.
        return static_cast<size_t>(mEnd) - mStart + 1u;
    }
  private:
    uint32_t mStart{1};
    uint32_t mEnd{0};

Patch walkthrough

  • Source/ThirdParty/ANGLE/src/common/mathutil.h — IndexRange constructor drops the derived mCount and simply stores mStart/mEnd with ASSERT(mStart <= mEnd); isEmpty() switches to mStart > mEnd; vertexCount() returns size_t via static_cast<size_t>(mEnd) - mStart + 1u (comment notes unsigned underflow is fine when empty); mCount member removed; default members become mStart{1}, mEnd{0}; hand-written operator== replaced by a defaulted friend comparing the two fields.
  • Source/ThirdParty/ANGLE/src/common/utilities_unittest.cpp — Adds IndexRange unit tests covering full [0,0xffffffff] ranges: ComputeIndexRange on {0,0xff,0xffffffff}, isEmpty()/vertexCount() edge cases including vertexCount() of [0,0xffffffff] equal to 0x100000000 as size_t and [1,0xffffffff] equal to 0xffffffff.
  • Source/ThirdParty/ANGLE/src/tests/gl_tests/WebGLCompatibilityTest.cpp — Adds end-to-end draw tests: an index buffer {0, 0xffffffff} must yield GL_INVALID_OPERATION for UNSIGNED_INT/SHORT/BYTE draws, while an in-range {0,1} buffer must draw with GL_NO_ERROR; also sets up a proper vertex attribute (glVertexAttribPointer) so the bound-buffer size is meaningful.
  • Source/ThirdParty/ANGLE/ANGLE.xcodeproj/project.pbxproj — Adds utilities_unittest.cpp to the build so the new IndexRange unit tests are compiled and run.

Background

ANGLE — Almost Native Graphics Layer Engine, the library WebKit uses to implement WebGL on top of platform graphics APIs. It performs the security-critical validation that WebGL draw calls only touch buffers the page legitimately allocated.

gl::IndexRange — A small struct summarizing the minimum and maximum element index referenced by an indexed draw (glDrawElements). ANGLE compares the range’s end against the number of vertices in each enabled attribute buffer to reject out-of-range draws.

Indexed draw validation — For glDrawElements ANGLE scans the index buffer (ComputeIndexRange) to find the largest index, then checks that index is within every bound vertex attribute buffer. If the check is skipped or fooled, the GPU/driver fetches vertices past the buffer end.

Inclusive-range count overflow — Representing an inclusive [start,end] span by a count = end - start + 1 overflows when the span covers the whole 32-bit space (count 0x100000000). In 32-bit arithmetic this wraps to 0, which the old code also used as the ’empty range’ sentinel, conflating a maximal range with an empty one.

GL_INVALID_OPERATION — The GL error a conformant implementation must raise when a draw references an index outside the bound vertex data. The added tests assert this error appears for the 0xFFFFFFFF index and does not appear for in-range indices.

Vulnerability window

  1. Introduction — IndexRange stored a derived count and used count == 0 as the empty sentinel, an encoding that cannot distinguish a full 32-bit range from an empty one.
  2. Trigger setup — Attacker uploads an ELEMENT_ARRAY_BUFFER containing 0xFFFFFFFF (or spanning [0,0xFFFFFFFF]) and binds a small vertex attribute buffer.
  3. Validation bypass — ComputeIndexRange yields a range whose count wraps to 0; ANGLE treats it as empty/nothing-to-check and skips the bounds comparison.
  4. OOB fetch — glDrawElements proceeds and the GPU pipeline fetches vertex attributes for index 0xFFFFFFFF, far outside the bound buffer.
  5. Impact — Out-of-bounds vertex read in the GPU-command path; depending on driver, this leaks memory into rendered output or crashes the process.
  6. Fix — 315326@main switches to explicit start/end with size_t vertexCount() and start>end emptiness, so the maximal range keeps end==0xFFFFFFFF and the bounds check correctly rejects it.

Proof of concept

The added WebGLCompatibilityTest case binds an index buffer whose maximum element is 0xFFFFFFFF against a small vertex buffer and asserts glDrawElements now raises GL_INVALID_OPERATION (pre-fix it did not, allowing the OOB). The in-range {0,1} buffer confirms legitimate draws still succeed. Translated to JS, a WebGL page performs the same sequence: gl.bufferData on ELEMENT_ARRAY_BUFFER with a Uint32Array([0, 0xFFFFFFFF]) then gl.drawElements(gl.LINES, 2, gl.UNSIGNED_INT, 0).

constexpr GLuint kIndexData2[] = {
    0,
    std::numeric_limits<GLuint>::max(),
};
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(kIndexData2), kIndexData2, GL_DYNAMIC_DRAW);

glDrawElements(GL_LINES, 2, GL_UNSIGNED_INT, 0);
EXPECT_GL_ERROR(GL_INVALID_OPERATION);

glDrawElements(GL_LINES, 2, GL_UNSIGNED_SHORT, reinterpret_cast<void *>(2));
EXPECT_GL_ERROR(GL_INVALID_OPERATION);

constexpr GLuint kIndexData3[] = {0, 1};
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(kIndexData3), kIndexData3, GL_DYNAMIC_DRAW);

glDrawElements(GL_LINES, 2, GL_UNSIGNED_INT, 0);
EXPECT_GL_NO_ERROR();

Exploitation

  1. Setup — Any page creates a WebGL context, allocates a small vertex attribute buffer, and uploads an index buffer containing a maximal index value.
  2. Primitive — drawElements bypasses index validation, causing the vertex pipeline to read attributes out of bounds – an attacker-influenced OOB read in the GPU-command/driver path.
  3. Info leak / instability — Depending on driver and buffer layout, out-of-bounds vertex data can surface in rendered pixels (readable via readPixels/toDataURL) or destabilize the process. Reliability is driver-dependent.
  4. Escalation — Best characterized as an OOB-read/info-leak and GPU-process reliability bug; turning it into memory corruption depends on driver behavior and is not provided by this bug alone.

Detection & hunting

For defenders and SOC / detection engineers:

  • Maximal index values in WebGL element buffers
  • GPU-process crashes during indexed draws
  • readPixels after suspicious draws

Audit directions

  • IndexRange consumers
  • Other inclusive-range encodings
  • ComputeIndexRange paths
  • Primitive-restart interaction

Before / after

Loading diff…