CVE-2026-17721
Overview
Files Changed
src/libANGLE/renderer/vulkan/vk_helpers.cppsrc/tests/gl_tests/CopyTexImageTest.cpp
Patch
From a4eea1fbedace7a03bae52cd1bb9d6ebdfffb0f7 Mon Sep 17 00:00:00 2001 From: Amirali Abdolrashidi <[email protected]> Date: Tue, 16 Jun 2026 18:04:22 -0700 Subject: [PATCH] Vulkan: Fix layer index/count for update from FBO Currently, if glCopyTexSubImage3D() is called with a non-zero z-offset and the CanCopyWithDraw() path (in copySubImageImpl()) is not taken, stageSubresourceUpdateFromFramebuffer() in ImageHelper is called, in which the buffer-to-image struct `copyToImage` is used to stage an update. However, since the baseLayer in the ImageIndex object (`index`) for the 3D texture has been initialized to the z-offset, the related values in the struct (baseArrayLayer and layerCount) are set up incorrectly. * Updated stageSubresourceUpdateFromFramebuffer() so baseArrayLayer and layerCount are set to 0 and 1 unless the image is an array type, in which case the data from `index` is applied. * Added unit test to CopyTexImageTestES3: Snorm3DTextureNonZeroOffset * It copies from an FBO bound to an RGBA8 SNORM texture to a non-zero z-offset of a 3D RGB8 SNORM texture. * The SNORM formats take the CPU readback path on some platforms. (stageSubresourceUpdateFromFramebuffer()) Bug: chromium:523495723 Change-Id: I08bf787dca05d74a5ab22983dfd0563a0eff002d Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7966117 Reviewed-by: Shahbaz Youssefi <[email protected]> Commit-Queue: Amirali Abdolrashidi <[email protected]> --- diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.cpp b/src/libANGLE/renderer/vulkan/vk_helpers.cpp index a42b7e1..acf6197 100644 --- a/src/libANGLE/renderer/vulkan/vk_helpers.cpp +++ b/src/libANGLE/renderer/vulkan/vk_helpers.cpp @@ -9604,6 +9604,17 @@ gl::LevelIndex updateLevelGL(index.getLevelIndex()); + // If the image is not an array type, the base layer index and layer count should be 0 and 1 + // respectively. + uint32_t layerIndex = index.hasLayer() ? index.getLayerIndex() : 0; + uint32_t layerCount = index.getLayerCount(); + if (index.getType() == gl::TextureType::_3D) + { + ASSERT(static_cast<uint32_t>(dstOffset.z) == layerIndex); + layerIndex = 0; + layerCount = 1; + } + // 3- enqueue the destination image subresource update VkBufferImageCopy copyToImage = {}; copyToImage.bufferOffset = static_cast<VkDeviceSize>(stagingOffset); @@ -9611,8 +9622,8 @@ copyToImage.bufferImageHeight = clippedRectangle.height; copyToImage.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; copyToImage.imageSubresource.mipLevel = updateLevelGL.get(); - copyToImage.imageSubresource.baseArrayLayer = index.hasLayer() ? index.getLayerIndex() : 0; - copyToImage.imageSubresource.layerCount = index.getLayerCount(); + copyToImage.imageSubresource.baseArrayLayer = layerIndex; + copyToImage.imageSubresource.layerCount = layerCount; gl_vk::GetOffset(dstOffset, ©ToImage.imageOffset); gl_vk::GetExtent(dstExtent, ©ToImage.imageExtent); diff --git a/src/tests/gl_tests/CopyTexImageTest.cpp b/src/tests/gl_tests/CopyTexImageTest.cpp index eed2be6..eb78cd3 100644 --- a/src/tests/gl_tests/CopyTexImageTest.cpp +++ b/src/tests/gl_tests/CopyTexImageTest.cpp @@ -1717,6 +1717,75 @@ GLColor::yellow); } +// Test glCopyTexSubImage3D() for an SNORM 3D texture to make sure it updates the correct depth. +TEST_P(CopyTexImageTestES3, Snorm3DTextureNonZeroOffset) +{ + ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_render_snorm")); + + constexpr size_t kWidth = 9; + constexpr size_t kHeight = 4; + constexpr size_t kDepth = 8; + constexpr size_t kZOffset = 3; + static_assert(kZOffset < kDepth); + + GLFramebuffer fbo; + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + + // Bind an RGBA SNORM texture to the framebuffer and clear it to a single color. + GLTexture srcTexture; + glBindTexture(GL_TEXTURE_2D, srcTexture); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8_SNORM, kWidth, kHeight); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, srcTexture, 0); + ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER); + + glClearColor(0.0f, 1.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ASSERT_GL_NO_ERROR(); + + // The destination texture is defined as 3D with RGB SNORM format and cleared. Then the bound + // framebuffer is copied into a non-zero depth of this texture. + GLTexture dstTexture; + glBindTexture(GL_TEXTURE_3D, dstTexture); + glTexStorage3D(GL_TEXTURE_3D, 1, GL_RGB8_SNORM, kWidth, kHeight, kDepth); + + std::vector<uint8_t> colorWhiteRGB8Snorm(kWidth * kHeight * kDepth * 3, 0x7F); + glTexSubImage3D(GL_TEXTURE_3D, 0, 0, 0, 0, kWidth, kHeight, kDepth, GL_RGB, GL_BYTE, + colorWhiteRGB8Snorm.data()); + glCopyTexSubImage3D(GL_TEXTURE_3D, 0, 0, 0, kZOffset, 0, 0, kWidth, kHeight); + ASSERT_GL_NO_ERROR(); + + // Verify that the correct depth of the 3D texture has been copied into and the rest of it + // remains intact. This is done through sampling from each depth of the 3D texture and drawing + // to the FBO which is bound to an RGBA8 texture. + GLTexture outTexture; + glBindTexture(GL_TEXTURE_2D, outTexture); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kWidth, kHeight); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, outTexture, 0); + ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER); + + constexpr char k3DSampleFromDepthFS[] = R"(#version 300 es +precision highp float; +uniform highp sampler3D tex3D; +uniform int u_depth; +out vec4 fragColor; +void main() { + fragColor = texelFetch(tex3D, ivec3(0, 0, u_depth), 0); +})"; + ANGLE_GL_PROGRAM(tex3DProgram, essl3_shaders::vs::Simple(), k3DSampleFromDepthFS); + glUseProgram(tex3DProgram); + glUniform1i(glGetUniformLocation(tex3DProgram, "tex3D"), 0); + + for (size_t z = 0; z < kDepth; z++) + { + glUniform1i(glGetUniformLocation(tex3DProgram, "u_depth"), z); + drawQuad(tex3DProgram, std::string(essl3_shaders::PositionAttrib()), 0.0f); + ASSERT_GL_NO_ERROR(); + + GLColor expectedColor = z == kZOffset ? GLColor::green : GLColor::white; + EXPECT_PIXEL_RECT_EQ(0, 0, kWidth, kHeight, expectedColor); + } +} + // Based on the WebGL conformance test copy-texture-image-same-texture.html. TEST_P(CopyTexImageLumaWorkaroundTestES3, SameTextureDifferentLevelLuminanceAlpha) {
Regression Test / PoC
diff --git a/src/tests/gl_tests/CopyTexImageTest.cpp b/src/tests/gl_tests/CopyTexImageTest.cpp
index eed2be6..eb78cd3 100644
--- a/src/tests/gl_tests/CopyTexImageTest.cpp
+++ b/src/tests/gl_tests/CopyTexImageTest.cpp
@@ -1717,6 +1717,75 @@
GLColor::yellow);
}
+// Test glCopyTexSubImage3D() for an SNORM 3D texture to make sure it updates the correct depth.
+TEST_P(CopyTexImageTestES3, Snorm3DTextureNonZeroOffset)
+{
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_render_snorm"));
+
+ constexpr size_t kWidth = 9;
+ constexpr size_t kHeight = 4;
+ constexpr size_t kDepth = 8;
+ constexpr size_t kZOffset = 3;
+ static_assert(kZOffset < kDepth);
+
+ GLFramebuffer fbo;
+ glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+
+ // Bind an RGBA SNORM texture to the framebuffer and clear it to a single color.
+ GLTexture srcTexture;
+ glBindTexture(GL_TEXTURE_2D, srcTexture);
+ glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8_SNORM, kWidth, kHeight);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, srcTexture, 0);
+ ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+ glClearColor(0.0f, 1.0f, 0.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+ ASSERT_GL_NO_ERROR();
+
+ // The destination texture is defined as 3D with RGB SNORM format and cleared. Then the bound
+ // framebuffer is copied into a non-zero depth of this texture.
+ GLTexture dstTexture;
+ glBindTexture(GL_TEXTURE_3D, dstTexture);
+ glTexStorage3D(GL_TEXTURE_3D, 1, GL_RGB8_SNORM, kWidth, kHeight, kDepth);
+
+ std::vector<uint8_t> colorWhiteRGB8Snorm(kWidth * kHeight * kDepth * 3, 0x7F);
+ glTexSubImage3D(GL_TEXTURE_3D, 0, 0, 0, 0, kWidth, kHeight, kDepth, GL_RGB, GL_BYTE,
+ colorWhiteRGB8Snorm.data());
+ glCopyTexSubImage3D(GL_TEXTURE_3D, 0, 0, 0, kZOffset, 0, 0, kWidth, kHeight);
+ ASSERT_GL_NO_ERROR();
+
+ // Verify that the correct depth of the 3D texture has been copied into and the rest of it
+ // remains intact. This is done through sampling from each depth of the 3D texture and drawing
+ // to the FBO which is bound to an RGBA8 texture.
+ GLTexture outTexture;
+ glBindTexture(GL_TEXTURE_2D, outTexture);
+ glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kWidth, kHeight);
+ glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, outTexture, 0);
+ ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+ constexpr char k3DSampleFromDepthFS[] = R"(#version 300 es
+precision highp float;
+uniform highp sampler3D tex3D;
+uniform int u_depth;
+out vec4 fragColor;
+void main() {
+ fragColor = texelFetch(tex3D, ivec3(0, 0, u_depth), 0);
+})";
+ ANGLE_GL_PROGRAM(tex3DProgram, essl3_shaders::vs::Simple(), k3DSampleFromDepthFS);
+ glUseProgram(tex3DProgram);
+ glUniform1i(glGetUniformLocation(tex3DProgram, "tex3D"), 0);
+
+ for (size_t z = 0; z < kDepth; z++)
+ {
+ glUniform1i(glGetUniformLocation(tex3DProgram, "u_depth"), z);
+ drawQuad(tex3DProgram, std::string(essl3_shaders::PositionAttrib()), 0.0f);
+ ASSERT_GL_NO_ERROR();
+
+ GLColor expectedColor = z == kZOffset ? GLColor::green : GLColor::white;
+ EXPECT_PIXEL_RECT_EQ(0, 0, kWidth, kHeight, expectedColor);
+ }
+}
+
// Based on the WebGL conformance test copy-texture-image-same-texture.html.
TEST_P(CopyTexImageLumaWorkaroundTestES3, SameTextureDifferentLevelLuminanceAlpha)
{
Original Bug Report
Potential Out-of-Bounds Write in ANGLE Vulkan backend via copyTexSubImage3D
Flapjack, 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: When using copyTexSubImage3D with a non-zero zoffset, ANGLE’s Vulkan backend creates an invalid VkBufferImageCopy structure with a non-zero baseArrayLayer for 3D textures. This violates Vulkan specifications and causes the GPU driver to calculate an out-of-bounds destination address, allowing arbitrary writes to GPU memory.
Affected files:
third_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cppthird_party/angle/src/libANGLE/Context.cpp
Estimated timestamp from git blame: Unknown (Google3 checkout)
Description
A potential Out-Of-Bounds (OOB) Write vulnerability exists in the ANGLE Vulkan backend when processing updates for 3D textures via the copyTexSubImage3D WebGL 2.0 API. By providing a non-zero zoffset, an attacker can trick ANGLE into constructing a malformed VkBufferImageCopy command. This command violates the Vulkan specification, causing the underlying graphics driver to perform memory copy operations out-of-bounds in GPU memory.
Root Cause Analysis
The vulnerability stems from how ANGLE tracks the Z-slice offset (depth) when staging updates for 3D textures from a framebuffer.
-
In
third_party/angle/src/libANGLE/Context.cpp,Context::copyTexSubImage3Dcreates anImageIndexrepresenting the subresource destination:ImageIndex index = ImageIndex::MakeFromType(TextureTargetToType(target), level, zoffset);For a 3D texture, this explicitly sets the internalmLayerIndexto the value ofzoffset. -
The offset is also separately tracked in a
gl::Offset destOffset(xoffset, yoffset, zoffset);. -
If the copy falls back to a CPU readback path (e.g., due to incompatible format combinations between the framebuffer and the 3D texture), execution reaches
ImageHelper::stageSubresourceUpdateFromFramebufferinthird_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cpp. -
Here, ANGLE populates a
VkBufferImageCopystructure (copyToImage) to instruct the Vulkan driver to copy the readback data. It incorrectly uses theImageIndexto set thebaseArrayLayer:copyToImage.imageSubresource.baseArrayLayer = index.hasLayer() ? index.getLayerIndex() : 0;BecausemLayerIndexwas set tozoffset,index.hasLayer()evaluates to true, andbaseArrayLayerbecomeszoffset. -
Concurrently, it sets the
Zimage offset:gl_vk::GetOffset(dstOffset, ©ToImage.imageOffset);This setscopyToImage.imageOffset.ztozoffset. -
According to the Vulkan API Specification (VUID-VkBufferImageCopy-imageSubresource-07971), for any image of type
VK_IMAGE_TYPE_3D, thebaseArrayLayermember must be exactly0.
By passing a non-zero baseArrayLayer alongside a non-zero imageOffset.z, the Vulkan driver’s destination address calculation processes a massive “double-offset”. It shifts the address by zoffset * VolumeSize (interpreting the layer as a full 3D volume) plus zoffset * SliceSize. Because 3D textures allocate only a single volume layer, this calculation results in an address far outside the bounds of the VkDeviceMemory allocated for the texture.
Potential Attacker Steps
(Note: These are suggested steps based on source code analysis; a full working proof of concept has not been executed yet.)
- The attacker creates a WebGL 2.0 context.
- Using JavaScript, the attacker creates a 3D texture (
gl.texImage3D). - The attacker creates a framebuffer and attaches a 2D texture or renderbuffer to it, filling it with attacker-controlled pixel data.
- The attacker carefully selects a format combination for the framebuffer and 3D texture that cannot be copied using fast GPU transfer paths, forcing ANGLE into the CPU readback fallback (
stageSubresourceUpdateFromFramebuffer). - The attacker calls
gl.copyTexSubImage3D(gl.TEXTURE_3D, 0, 0, 0, N, 0, 0, W, H)with a large, non-zerozoffset(N). - The Vulkan driver executes the out-of-bounds DMA copy, writing the attacker’s framebuffer data over adjacent GPU memory allocations (e.g., command buffers, shader binaries).
- The attacker leverages this to hijack GPU execution, achieving Remote Code Execution in the GPU process. On platforms where the GPU process is unsandboxed (like Android), this leads to a full sandbox escape.
Suggested Fix
In ImageHelper::stageSubresourceUpdateFromFramebuffer (and similarly in ImageHelper::stageSubresourceUpdate at line 8654), explicitly verify whether the target is an array texture before utilizing index.getLayerIndex(). If the texture is a 3D texture (i.e., gl::IsArrayTextureType(index.getType()) is false or the image type is VK_IMAGE_TYPE_3D), baseArrayLayer must be strictly set to 0.
For example:
if (gl::IsArrayTextureType(index.getType()))
{
copyToImage.imageSubresource.baseArrayLayer = index.hasLayer() ? index.getLayerIndex() : 0;
copyToImage.imageSubresource.layerCount = index.getLayerCount();
}
else
{
copyToImage.imageSubresource.baseArrayLayer = 0;
copyToImage.imageSubresource.layerCount = 1;
// Ensure dstOffset handles the Z-slice offset appropriately.
}
This mirrors the correct handling already present in ImageHelper::stageSubresourceUpdateFromImage.
Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb
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.