Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in ANGLE
DescriptionOut of bounds read in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker536598187
Fix commit91d2d125ec00 (angle/angle) +33/-1
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Files Changed

  • src/libANGLE/renderer/vulkan/BufferVk.cpp
  • src/tests/gl_tests/IndexBufferOffsetTest.cpp
From 91d2d125ec0054f970594b29660f1cf3d172a8f1 Mon Sep 17 00:00:00 2001
From: wangra <wangra@google.com>
Date: Sat, 01 Aug 2026 14:11:47 -0400
Subject: [PATCH] Vulkan: Map only remaining buffer size in getIndexRange

Correct the mapped buffer range length in getIndexRange to only map the
remaining buffer size. This prevents staging buffer copies from reading
past the end of the suballocation.

Test: angle_end2end_tests --gtest_filter="*DrawAtOffsetWithClientSideVertexData*"
Bug: b/536598187
Change-Id: I6e1bb638562fc165a013478c16da639c1cfd5071
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8178489
Reviewed-by: Charlie Lao <cclao@google.com>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Commit-Queue: Ran Wang <wangra@google.com>
---

diff --git a/src/libANGLE/renderer/vulkan/BufferVk.cpp b/src/libANGLE/renderer/vulkan/BufferVk.cpp
index 3bfcb32..ebfc7eb 100644
--- a/src/libANGLE/renderer/vulkan/BufferVk.cpp
+++ b/src/libANGLE/renderer/vulkan/BufferVk.cpp
@@ -800,6 +800,7 @@
 {
     vk::Renderer *renderer = contextVk->getRenderer();
     ASSERT(mBuffer.valid());
+    ASSERT(offset + length <= static_cast<VkDeviceSize>(mState.getSize()));
 
     // Record map call parameters in case this call is from angle internal (the access/offset/length
     // will be inconsistent from mState).
@@ -986,7 +987,7 @@
     ANGLE_TRACE_EVENT0("gpu.angle", "BufferVk::getIndexRange");
 
     void *mapPtr;
-    ANGLE_TRY(mapRangeForReadAccessOnly(contextVk, offset, getSize(), &mapPtr));
+    ANGLE_TRY(mapRangeForReadAccessOnly(contextVk, offset, getSize() - offset, &mapPtr));
     *outRange = gl::ComputeIndexRange(type, mapPtr, count, primitiveRestartEnabled);
     ANGLE_TRY(unmapReadAccessOnly(contextVk));
 
diff --git a/src/tests/gl_tests/IndexBufferOffsetTest.cpp b/src/tests/gl_tests/IndexBufferOffsetTest.cpp
index 9629d77..c79d908 100644
--- a/src/tests/gl_tests/IndexBufferOffsetTest.cpp
+++ b/src/tests/gl_tests/IndexBufferOffsetTest.cpp
@@ -578,6 +578,37 @@
     EXPECT_GL_NO_ERROR();
 }
 
+// Draw with an index buffer offset while sourcing vertex data from client memory.
+TEST_P(IndexBufferOffsetTest, DrawAtOffsetWithClientSideVertexData)
+{
+    constexpr size_t kIndexCount = 6;
+    constexpr size_t kBufferSize = 1024;
+    constexpr size_t kOffset     = kBufferSize - kIndexCount * sizeof(GLushort);
+
+    const GLushort indexData[kIndexCount] = {0, 1, 2, 1, 2, 3};
+    std::vector<GLubyte> bufferData(kBufferSize, 0);
+    ANGLE_UNSAFE_TODO(memcpy(&bufferData[kOffset], indexData, sizeof(indexData)));
+
+    glBufferData(GL_ELEMENT_ARRAY_BUFFER, kBufferSize, bufferData.data(), GL_STATIC_DRAW);
+
+    glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
+    glClear(GL_COLOR_BUFFER_BIT);
+
+    glUseProgram(mProgram);
+    glUniform4f(mColorUniformLocation, 1.0f, 0.0f, 0.0f, 1.0f);
+
+    // Source vertex data from client memory so the backend must compute the index range.
+    const GLfloat vertices[] = {-1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 1.0f};
+    glBindBuffer(GL_ARRAY_BUFFER, 0);
+    glVertexAttribPointer(mPositionAttributeLocation, 2, GL_FLOAT, GL_FALSE, 0, vertices);
+    glEnableVertexAttribArray(mPositionAttributeLocation);
+
+    glDrawElements(GL_TRIANGLES, kIndexCount, GL_UNSIGNED_SHORT, reinterpret_cast<void *>(kOffset));
+
+    EXPECT_PIXEL_COLOR_EQ(64, 64, GLColor::red);
+    EXPECT_GL_NO_ERROR();
+}
+
 // Uses index buffer offset and 2 drawElement calls one of the other with different counts,
 // makes sure the second drawElement call will have its data available.
 TEST_P(IndexBufferOffsetTest, DrawWithDifferentCountsSameOffset)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/IndexBufferOffsetTest.cpp b/src/tests/gl_tests/IndexBufferOffsetTest.cpp
index 9629d77..c79d908 100644
--- a/src/tests/gl_tests/IndexBufferOffsetTest.cpp
+++ b/src/tests/gl_tests/IndexBufferOffsetTest.cpp
@@ -578,6 +578,37 @@
     EXPECT_GL_NO_ERROR();
 }
 
+// Draw with an index buffer offset while sourcing vertex data from client memory.
+TEST_P(IndexBufferOffsetTest, DrawAtOffsetWithClientSideVertexData)
+{
+    constexpr size_t kIndexCount = 6;
+    constexpr size_t kBufferSize = 1024;
+    constexpr size_t kOffset     = kBufferSize - kIndexCount * sizeof(GLushort);
+
+    const GLushort indexData[kIndexCount] = {0, 1, 2, 1, 2, 3};
+    std::vector<GLubyte> bufferData(kBufferSize, 0);
+    ANGLE_UNSAFE_TODO(memcpy(&bufferData[kOffset], indexData, sizeof(indexData)));
+
+    glBufferData(GL_ELEMENT_ARRAY_BUFFER, kBufferSize, bufferData.data(), GL_STATIC_DRAW);
+
+    glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
+    glClear(GL_COLOR_BUFFER_BIT);
+
+    glUseProgram(mProgram);
+    glUniform4f(mColorUniformLocation, 1.0f, 0.0f, 0.0f, 1.0f);
+
+    // Source vertex data from client memory so the backend must compute the index range.
+    const GLfloat vertices[] = {-1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 1.0f};
+    glBindBuffer(GL_ARRAY_BUFFER, 0);
+    glVertexAttribPointer(mPositionAttributeLocation, 2, GL_FLOAT, GL_FALSE, 0, vertices);
+    glEnableVertexAttribArray(mPositionAttributeLocation);
+
+    glDrawElements(GL_TRIANGLES, kIndexCount, GL_UNSIGNED_SHORT, reinterpret_cast<void *>(kOffset));
+
+    EXPECT_PIXEL_COLOR_EQ(64, 64, GLColor::red);
+    EXPECT_GL_NO_ERROR();
+}
+
 // Uses index buffer offset and 2 drawElement calls one of the other with different counts,
 // makes sure the second drawElement call will have its data available.
 TEST_P(IndexBufferOffsetTest, DrawWithDifferentCountsSameOffset)
Loading diff…

Original Bug Report

reported by aw...@chromium.org

GPU OOB read in Vulkan BufferVk::getIndexRange via incorrect length parameter

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: An out-of-bounds GPU-side read vulnerability exists in ANGLE’s Vulkan backend because BufferVk::getIndexRange incorrectly passes the full buffer size instead of the remaining size to mapRangeForReadAccessOnly. On discrete GPUs, this causes vkCmdCopyBuffer to read past the end of the suballocation when mapping device-local memory. The vulnerable path is reachable via WebGL2 by forcing streaming vertex attribute emulation.

Affected files:

  • third_party/angle/src/libANGLE/renderer/vulkan/BufferVk.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/BufferVk.h
  • third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp

Estimated timestamp from git blame: 2021-05-08

1. Summary of the Issue (Meant for Human Triage)

ANGLE’s Vulkan backend contains a potential out-of-bounds (OOB) memory read vulnerability in the BufferVk::getIndexRange implementation due to an incorrect buffer length calculation. When computing an index range, the code maps a range of the buffer starting at a client-controlled offset. However, instead of passing the remaining buffer size (getSize() - offset), it passes the full size of the buffer (getSize()) as the length argument to mapRangeForReadAccessOnly.

On systems using discrete Vulkan GPUs (such as dedicated NVIDIA or AMD GPUs on Linux, ChromeOS, or Windows), index buffers are typically allocated in device-local memory (!isHostVisible()). Consequently, the mapping requests are routed to handleDeviceLocalBufferMap, which allocates a staging buffer and schedules a GPU-to-GPU copy via vkCmdCopyBuffer to pull the data into host-visible memory. Because vkCmdCopyBuffer is instructed to copy getSize() bytes starting from the offset, it attempts to read up to offset bytes past the end of the buffer’s designated suballocation within the shared BufferBlock pool. This leads to a GPU-side out-of-bounds read, violating Vulkan specification parameter constraints (VUID-vkCmdCopyBuffer-srcOffset-00113) and potentially exposing data from adjacent suballocations into the staging buffer.

Crucially, this path can be reached by a compromised renderer or standard WebGL2 content by setting a vertex attribute divisor greater than 255. This forces ANGLE to use a streaming vertex attribute emulation path that calls getIndexRange without being gated by standard robust buffer access checks.


2. Proof-of-Concept & Detailed Execution Flow

The following is a step-by-step sequence of events that results in the vulnerability, based on static analysis of the codebase:

  1. Environment Setup: The attacker requires a target running Chrome on an OS equipped with a discrete Vulkan GPU (VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU). On these systems, ANGLE’s Vulkan backend canPreferDeviceLocalMemoryHostVisible returns false (third_party/angle/src/libANGLE/renderer/vulkan/vk_renderer.cpp:5443).
  2. Buffer Creation: The attacker creates an index buffer using standard WebGL2 API calls: const ib = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, 0x100000, gl.STATIC_DRAW);.
  3. Device-Local Allocation: Because STATIC_DRAW is used, BufferVk::GetPreferredMemoryType selects kDeviceLocalFlags without the HOST_VISIBLE bit (third_party/angle/src/libANGLE/renderer/vulkan/BufferVk.cpp:83-86). The buffer’s memory (mBuffer) is suballocated from a larger, shared BufferBlock, and mBuffer.isHostVisible() evaluates to false.
  4. Buffer Population: The attacker populates a small set of valid indices near the end of the allocated buffer space: gl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, 0xFFFF0, new Uint32Array([0,0,0]));.
  5. Streaming Emulation Trigger: The attacker binds a vertex buffer to an attribute and calls gl.vertexAttribDivisor(0, 0x10000).
  6. Divisor Validation Bypass: In vk_renderer.cpp:3958-3960, ANGLE strictly caps mMaxVertexAttribDivisor at 255. However, there is no client-side or WebGL frontend check that caps the divisor provided by the attacker.
  7. Streaming Mask Flagging: When ANGLE processes the vertex array state in VertexArrayVk::syncState, it recognizes that 0x10000 > renderer->getMaxVertexAttribDivisor(). It sets a bit in mDivisorExceedMaxSupportedValueBindingMask (VertexArrayVk.cpp:1009-1011).
  8. Attribute Opt-in: Subsequently, VertexArrayVk::syncState folds the attributes bound to this binding into mStreamingVertexAttribsMask (VertexArrayVk.cpp:1037-1044). This forces ANGLE to emulate the vertex fetch for this attribute using CPU streaming.
  9. Draw Call Execution: The attacker executes an indexed draw call with a large offset: gl.drawElementsInstanced(gl.TRIANGLES, 3, gl.UNSIGNED_INT, 0xFFFF0, 1);. The WebGL frontend validation allows this because offset + count * typeSize (0xFFFF0 + 12) is less than the buffer’s size (0x100000).
  10. Draw Setup: ContextVk::setupDraw detects the streaming attribute (getStreamingVertexAttribsMask().any()) and unconditionally invokes VertexArrayVk::updateStreamedAttribs (ContextVk.cpp:1663-1670). This path ignores the standard isBufferAccessValidationEnabled() check.
  11. Index Range Lookup: To know which vertices to stream, updateStreamedAttribs calls GetVertexRangeInfo (renderer_utils.cpp:1610-1615), which delegates to vao->getIndexRange(...), eventually landing in BufferVk::getIndexRange (BufferVk.cpp:968).
  12. The Vulnerable Map Call: Inside BufferVk::getIndexRange, ANGLE attempts to map the index buffer to read the index data. It calls ANGLE_TRY(mapRangeForReadAccessOnly(contextVk, offset, getSize(), &mapPtr)); (BufferVk.cpp:989).
  13. Root Cause Failure: The length argument is incorrectly passed as the full size of the buffer (getSize()), rather than the correct remaining size (getSize() - offset). Contrast this with getSubData (BufferVk.cpp:963), which correctly calculates bounds.
  14. Device-Local Fallthrough: mapRangeForReadAccessOnly forwards to mapRangeImpl. Because hostVisible is false for this device-local buffer, it executes handleDeviceLocalBufferMap(contextVk, offset, length, mapPtrBytes) (BufferVk.cpp:841), where length is 0x100000.
  15. Staging Buffer Allocation: handleDeviceLocalBufferMap allocates a host-visible staging buffer large enough to hold size (allocStagingBuffer at BufferVk.cpp:676).
  16. OOB Copy Region Formulation: The code creates a VkBufferCopy region: VkBufferCopy copyRegion = {mBuffer.getOffset() + offset, mStagingBuffer.getOffset(), size}; (BufferVk.cpp:680).
  17. GPU Command Recording: The source read boundary is mBuffer.getOffset() + 0xFFFF0 + 0x100000. Since the buffer’s true allocated size is approximately 0x100000, the read bound overshoots the end of the suballocation by 0xFFFF0 bytes. The command commandBuffer->copyBuffer(...) is recorded (BufferVk.cpp:302).
  18. Sink Triggered: When the command buffer is submitted, the GPU executes vkCmdCopyBuffer. It reads 0xFFFF0 bytes past the end of the index buffer’s suballocation, copying data from adjacent suballocations within the shared BufferBlock into the trailing portion of the staging buffer. This is a GPU-memory-resident out-of-bounds read.

Suggested Remediation

The length parameter passed to mapRangeForReadAccessOnly in BufferVk::getIndexRange should be corrected to account for the offset, preventing the copy from exceeding the buffer bounds.

--- a/third_party/angle/src/libANGLE/renderer/vulkan/BufferVk.cpp
+++ b/third_party/angle/src/libANGLE/renderer/vulkan/BufferVk.cpp
@@ -986,7 +986,7 @@
     ANGLE_TRACE_EVENT0("gpu.angle", "BufferVk::getIndexRange");
 
     void *mapPtr;
-    ANGLE_TRY(mapRangeForReadAccessOnly(contextVk, offset, getSize(), &mapPtr));
+    ANGLE_TRY(mapRangeForReadAccessOnly(contextVk, offset, getSize() - offset, &mapPtr));
     *outRange = gl::ComputeIndexRange(type, mapPtr, count, primitiveRestartEnabled);
     ANGLE_TRY(unmapReadAccessOnly(contextVk));

3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)

Prior Critic Verdict: > * Severity: Medium (S2) > * Brief Notes / Reasoning: > The report accurately identifies an out-of-bounds read vulnerability in ANGLE’s Vulkan backend. In BufferVk::getIndexRange, the length parameter passed to mapRangeForReadAccessOnly is incorrectly set to the total buffer size (getSize()) rather than the remaining size (getSize() - offset). When an attacker provides a non-zero offset (e.g., via gl.drawElementsInstanced), this causes the mapped range [offset, offset + getSize()] to exceed the source buffer’s actual size. On discrete GPUs where the index buffer is device-local, handleDeviceLocalBufferMap is invoked, issuing a VkBufferCopy that reads up to offset bytes past the end of the suballocation inside the shared BufferBlock VkBuffer. >
> The trigger path using WebGL2 vertexAttribDivisor > 255 to force the streaming-attribs path (which bypasses standard robust buffer access checks) is valid, as ANGLE limits mMaxVertexAttribDivisor to 255 but emulates larger divisors by setting the mStreamingVertexAttribsMask. >
> Per the severity guidelines for ‘GPU validating-layer gap’, a spec-invalid value reaches vkCmdCopyBuffer violating VUID-vkCmdCopyBuffer-srcOffset-00113 (if near the block tail) or ANGLE’s suballocation isolation. Because the destination staging buffer is appropriately sized (getSize()), there is no out-of-bounds write primitive. Furthermore, the out-of-bounds bytes read into the staging buffer’s tail are not directly observable or leaked back to the attacker. As the demonstrated consequence is a GPU-memory-resident out-of-bounds read only, the correct severity is Medium (S2). The runtime constraint to discrete GPUs does not qualify for the unsandboxed-Android Critical upgrade, which is consistent with the S2 rating.

Codebase Investigation Notes:

  • ContextVk::setupDraw (ContextVk.cpp:1663-1670) verified to call VertexArrayVk::updateStreamedAttribs based on getStreamingVertexAttribsMask().any().
  • VertexArrayVk::updateStreamedAttribs (VertexArrayVk.cpp:1448-1449) verified to call GetVertexRangeInfo.
  • mMaxVertexAttribDivisor capping (vk_renderer.cpp:3958-3960) confirmed to be std::numeric_limits<uint8_t>::max() (255).
  • VertexArrayVk::syncState (VertexArrayVk.cpp:1009-1011) confirmed to flag bindings where divisor > renderer->getMaxVertexAttribDivisor() into mDivisorExceedMaxSupportedValueBindingMask, which then adds to mStreamingVertexAttribsMask.
  • BufferVk::getIndexRange (BufferVk.cpp:989) confirmed to pass getSize() directly to mapRangeForReadAccessOnly without subtracting offset.
  • BufferVk::handleDeviceLocalBufferMap (BufferVk.cpp:680) confirmed to construct VkBufferCopy without validating offset + size <= mBuffer.getSize(), resulting in a source range of [mBuffer.getOffset() + offset, mBuffer.getOffset() + offset + getSize()].
  • No bounds checks were found that abort or reject a valid large offset (offset + count * typeSize <= getSize()) along the streaming emulation execution path.

Evaluated with Chrome root at commit: b96d2ec58f4f5f92b540a723966b199d6e9951b4


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker