CVE-2026-87586
Overview
Files Changed
src/libANGLE/renderer/vulkan/BufferVk.cppsrc/tests/gl_tests/IndexBufferOffsetTest.cpp
Patch
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)
Regression Test / PoC
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)
Original Bug Report
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.cppthird_party/angle/src/libANGLE/renderer/vulkan/BufferVk.hthird_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cppthird_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:
- 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 backendcanPreferDeviceLocalMemoryHostVisiblereturnsfalse(third_party/angle/src/libANGLE/renderer/vulkan/vk_renderer.cpp:5443). - 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);. - Device-Local Allocation: Because
STATIC_DRAWis used,BufferVk::GetPreferredMemoryTypeselectskDeviceLocalFlagswithout theHOST_VISIBLEbit (third_party/angle/src/libANGLE/renderer/vulkan/BufferVk.cpp:83-86). The buffer’s memory (mBuffer) is suballocated from a larger, sharedBufferBlock, andmBuffer.isHostVisible()evaluates to false. - 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]));. - Streaming Emulation Trigger: The attacker binds a vertex buffer to an attribute and calls
gl.vertexAttribDivisor(0, 0x10000). - Divisor Validation Bypass: In
vk_renderer.cpp:3958-3960, ANGLE strictly capsmMaxVertexAttribDivisorat 255. However, there is no client-side or WebGL frontend check that caps the divisor provided by the attacker. - Streaming Mask Flagging: When ANGLE processes the vertex array state in
VertexArrayVk::syncState, it recognizes that0x10000 > renderer->getMaxVertexAttribDivisor(). It sets a bit inmDivisorExceedMaxSupportedValueBindingMask(VertexArrayVk.cpp:1009-1011). - Attribute Opt-in: Subsequently,
VertexArrayVk::syncStatefolds the attributes bound to this binding intomStreamingVertexAttribsMask(VertexArrayVk.cpp:1037-1044). This forces ANGLE to emulate the vertex fetch for this attribute using CPU streaming. - 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 becauseoffset + count * typeSize(0xFFFF0 + 12) is less than the buffer’s size (0x100000). - Draw Setup:
ContextVk::setupDrawdetects the streaming attribute (getStreamingVertexAttribsMask().any()) and unconditionally invokesVertexArrayVk::updateStreamedAttribs(ContextVk.cpp:1663-1670). This path ignores the standardisBufferAccessValidationEnabled()check. - Index Range Lookup: To know which vertices to stream,
updateStreamedAttribscallsGetVertexRangeInfo(renderer_utils.cpp:1610-1615), which delegates tovao->getIndexRange(...), eventually landing inBufferVk::getIndexRange(BufferVk.cpp:968). - The Vulnerable Map Call: Inside
BufferVk::getIndexRange, ANGLE attempts to map the index buffer to read the index data. It callsANGLE_TRY(mapRangeForReadAccessOnly(contextVk, offset, getSize(), &mapPtr));(BufferVk.cpp:989). - Root Cause Failure: The
lengthargument is incorrectly passed as the full size of the buffer (getSize()), rather than the correct remaining size (getSize() - offset). Contrast this withgetSubData(BufferVk.cpp:963), which correctly calculates bounds. - Device-Local Fallthrough:
mapRangeForReadAccessOnlyforwards tomapRangeImpl. BecausehostVisibleis false for this device-local buffer, it executeshandleDeviceLocalBufferMap(contextVk, offset, length, mapPtrBytes)(BufferVk.cpp:841), wherelengthis0x100000. - Staging Buffer Allocation:
handleDeviceLocalBufferMapallocates a host-visible staging buffer large enough to holdsize(allocStagingBufferatBufferVk.cpp:676). - OOB Copy Region Formulation: The code creates a
VkBufferCopyregion:VkBufferCopy copyRegion = {mBuffer.getOffset() + offset, mStagingBuffer.getOffset(), size};(BufferVk.cpp:680). - GPU Command Recording: The source read boundary is
mBuffer.getOffset() + 0xFFFF0 + 0x100000. Since the buffer’s true allocated size is approximately0x100000, the read bound overshoots the end of the suballocation by0xFFFF0bytes. The commandcommandBuffer->copyBuffer(...)is recorded (BufferVk.cpp:302). - Sink Triggered: When the command buffer is submitted, the GPU executes
vkCmdCopyBuffer. It reads0xFFFF0bytes past the end of the index buffer’s suballocation, copying data from adjacent suballocations within the sharedBufferBlockinto 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 callVertexArrayVk::updateStreamedAttribsbased ongetStreamingVertexAttribsMask().any().VertexArrayVk::updateStreamedAttribs(VertexArrayVk.cpp:1448-1449) verified to callGetVertexRangeInfo.mMaxVertexAttribDivisorcapping (vk_renderer.cpp:3958-3960) confirmed to bestd::numeric_limits<uint8_t>::max()(255).VertexArrayVk::syncState(VertexArrayVk.cpp:1009-1011) confirmed to flag bindings wheredivisor > renderer->getMaxVertexAttribDivisor()intomDivisorExceedMaxSupportedValueBindingMask, which then adds tomStreamingVertexAttribsMask.BufferVk::getIndexRange(BufferVk.cpp:989) confirmed to passgetSize()directly tomapRangeForReadAccessOnlywithout subtractingoffset.BufferVk::handleDeviceLocalBufferMap(BufferVk.cpp:680) confirmed to constructVkBufferCopywithout validatingoffset + 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.