Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in ANGLE
DescriptionOut of bounds write in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker523495723
Fix commita4eea1fbedac (angle/angle) +82/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Files Changed

  • src/libANGLE/renderer/vulkan/vk_helpers.cpp
  • src/tests/gl_tests/CopyTexImageTest.cpp
From a4eea1fbedace7a03bae52cd1bb9d6ebdfffb0f7 Mon Sep 17 00:00:00 2001
From: Amirali Abdolrashidi <abdolrashidi@google.com>
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 <syoussefi@chromium.org>
Commit-Queue: Amirali Abdolrashidi <abdolrashidi@google.com>
---

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, &copyToImage.imageOffset);
     gl_vk::GetExtent(dstExtent, &copyToImage.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)
 {
Loading diff…

Regression Test / PoC

shipped with the fix
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)
 {
Loading diff…

Original Bug Report

reported by rj...@google.com

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.cpp
  • third_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.

  1. In third_party/angle/src/libANGLE/Context.cpp, Context::copyTexSubImage3D creates an ImageIndex representing the subresource destination: ImageIndex index = ImageIndex::MakeFromType(TextureTargetToType(target), level, zoffset); For a 3D texture, this explicitly sets the internal mLayerIndex to the value of zoffset.

  2. The offset is also separately tracked in a gl::Offset destOffset(xoffset, yoffset, zoffset);.

  3. 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::stageSubresourceUpdateFromFramebuffer in third_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cpp.

  4. Here, ANGLE populates a VkBufferImageCopy structure (copyToImage) to instruct the Vulkan driver to copy the readback data. It incorrectly uses the ImageIndex to set the baseArrayLayer: copyToImage.imageSubresource.baseArrayLayer = index.hasLayer() ? index.getLayerIndex() : 0; Because mLayerIndex was set to zoffset, index.hasLayer() evaluates to true, and baseArrayLayer becomes zoffset.

  5. Concurrently, it sets the Z image offset: gl_vk::GetOffset(dstOffset, &copyToImage.imageOffset); This sets copyToImage.imageOffset.z to zoffset.

  6. According to the Vulkan API Specification (VUID-VkBufferImageCopy-imageSubresource-07971), for any image of type VK_IMAGE_TYPE_3D, the baseArrayLayer member must be exactly 0.

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.)

  1. The attacker creates a WebGL 2.0 context.
  2. Using JavaScript, the attacker creates a 3D texture (gl.texImage3D).
  3. The attacker creates a framebuffer and attaches a 2D texture or renderbuffer to it, filling it with attacker-controlled pixel data.
  4. 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).
  5. The attacker calls gl.copyTexSubImage3D(gl.TEXTURE_3D, 0, 0, 0, N, 0, 0, W, H) with a large, non-zero zoffset (N).
  6. 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).
  7. 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.

View on issue tracker