Medium watch CVSS 4.3 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in ANGLE
DescriptionOut of bounds read in ANGLE
ComponentANGLE
Sub-componentVulkan
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker536598187
Fix commit91d2d125ec00 (angle/angle) +33/-1
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-07-19 (per vendor advisory)
Fix landed2026-08-03 (public git)
Disclosed2026-09-08
Patch gap36 days — fix public in source before the advisory

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 BufferVk method 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.

Key insight
The single mistake is passing the buffer’s total size as a length to a map call that already begins at a nonzero offset, double-counting offset and running the mapped window past the buffer end; the fix substitutes the true remaining length getSize() - offset.

Attack Path

  1. Create an index buffer The page allocates a GL_ELEMENT_ARRAY_BUFFER of a known size (e.g. 1024 bytes) via glBufferData and writes indices only near its tail.
  2. Force index-range computation Vertex data is sourced from client memory (glBindBuffer(GL_ARRAY_BUFFER, 0) plus glVertexAttribPointer to a CPU pointer), so on glDrawElements the Vulkan backend must call getIndexRange to learn the min/max index and stream the right vertex data.
  3. Supply a large offset The draw uses a byte offset near the end of the buffer (e.g. kBufferSize - 12); getIndexRange is invoked with this offset.
  4. Trigger the over-long map The vulnerable call maps getSize() bytes from offset, so the range extends offset bytes 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

An attacker who can issue WebGL/WebGL2 draw calls from a web page causes an out-of-bounds read of GPU-process memory adjacent to the index buffer’s Vulkan suballocation, with the only precondition being a draw that sources vertices from client memory at a nonzero index-buffer offset on the Vulkan backend. The read stays within the GPU process; whether the disclosed bytes can be reflected back to the attacker as an information leak is (inferred) and not shown by the patch. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3.

Files Changed

  • src/tests/gl_tests/IndexBufferOffsetTest.cpp
  • src/libANGLE/renderer/vulkan/BufferVk.cpp

Audit Directions

  • Offset-plus-full-size map lengths
    Search 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 bounds
    Audit 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 paths
    Review 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.
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)
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 [email protected]

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