CVE-2026-87586
Overview
Background
- ANGLE
- Chrome’s graphics abstraction layer that translates OpenGL ES calls onto native backends (here Vulkan), running inside the GPU process.
- `BufferVk`
- the Vulkan backend’s representation of a GL buffer object, backed by a Vulkan buffer suballocation of a fixed size.
- `getIndexRange`
- a
BufferVkmethod that maps an element (index) buffer and computes the min/max index it contains, needed when vertex data is sourced from client memory so the backend knows how much vertex data to stream. - Suballocation / staging copy
- GL buffers are placed in slices of a larger Vulkan allocation; a CPU read of device-local memory routes through a staging-buffer copy of exactly the mapped range, so an over-long map copies bytes beyond the slice.
Root Cause Analysis
BufferVk::getIndexRange maps the index buffer starting at the caller-supplied offset and, in the vulnerable code, requested a map length of getSize() — the buffer’s full size — rather than the number of bytes remaining after offset. When offset > 0, the mapped range [offset, offset + getSize()) extends offset bytes past the end of the buffer’s suballocation, violating the invariant that a mapped range must lie within the buffer. Because reading device-local memory triggers a staging-buffer copy of the entire mapped range, the backend copies (reads) memory past the end of the suballocation, producing the out-of-bounds read.
The fix changes the length argument to getSize() - offset, so the map covers only the bytes actually remaining in the buffer, and a new ASSERT(offset + length <= mState.getSize()) in the underlying map helper enforces the range invariant going forward.
offset and running the mapped window past the buffer end; the fix substitutes the true remaining length getSize() - offset.Attack Path
- Create an index buffer
The page allocates a
GL_ELEMENT_ARRAY_BUFFERof a known size (e.g. 1024 bytes) viaglBufferDataand writes indices only near its tail. - Force index-range computation
Vertex data is sourced from client memory (
glBindBuffer(GL_ARRAY_BUFFER, 0)plusglVertexAttribPointerto a CPU pointer), so onglDrawElementsthe Vulkan backend must callgetIndexRangeto learn the min/max index and stream the right vertex data. - Supply a large offset
The draw uses a byte offset near the end of the buffer (e.g.
kBufferSize - 12);getIndexRangeis invoked with thisoffset. - Trigger the over-long map
The vulnerable call maps
getSize()bytes fromoffset, so the range extendsoffsetbytes beyond the suballocation, and the staging-buffer copy reads that out-of-bounds tail. (inferred) The out-of-bounds bytes may be observable through their effect on downstream computation or timing, but the diff does not demonstrate a disclosure primitive.
Impact Assessment
Files Changed
src/tests/gl_tests/IndexBufferOffsetTest.cppsrc/libANGLE/renderer/vulkan/BufferVk.cpp
Audit Directions
- Offset-plus-full-size map lengthsSearch backend map/copy calls that pass a whole-object size as the length while also passing a nonzero start offset; the correct length is
size - offset. - Staging-copy range boundsAudit every device-local buffer read that routes through a staging-buffer copy, confirming the copied range is clamped to the suballocation and not derived from an unrelated size.
- Client-side vertex / index-range pathsReview code reached only when vertex data comes from client memory or when min/max index must be computed, since these less-common paths (like
getIndexRange) receive attacker-controlled offsets with weaker bounds checking.
Patch
From 91d2d125ec0054f970594b29660f1cf3d172a8f1 Mon Sep 17 00:00:00 2001 From: wangra <[email protected]> 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 <[email protected]> Reviewed-by: Shahbaz Youssefi <[email protected]> Commit-Queue: Ran Wang <[email protected]> --- 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.