CVE-2026-9915
Overview
Files Changed
src/libANGLE/ErrorStrings.hsrc/libANGLE/renderer/vulkan/vk_helpers.cppsrc/libANGLE/validationES3.cppsrc/tests/angle_end2end_tests_expectations.txtsrc/tests/gl_tests/TextureTest.cpp
Patch
From 210ffede00f208573d16dbc3d4b4dfb2670c438d Mon Sep 17 00:00:00 2001 From: Ken Russell <[email protected]> Date: Fri, 24 Apr 2026 20:28:10 -0700 Subject: [PATCH] Scale UV plane offsets for YUV textures. The U and V channels are always half-resolution. Scale the offsets appropriately when copying data into those planes of the texture. Add validation rejecting glTexSubImage2D uploads to ANGLE_yuv_internal_format textures when any of the xoffset, yoffset, width or height are odd. The results are ill-defined. Add new unit tests performing sub-image uploads at non-zero offsets to these texture types. 3-plane textures are not rendering correctly (the result of sampling from them was never tested before) so the test is skipped in that configuration. Fixed: chromium:500063836 Bug: chromium:506193593 Change-Id: Ie89e6f501ffe7b30f6a87bbb64e7ac4a5d945422 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7793855 Auto-Submit: Kenneth Russell <[email protected]> Commit-Queue: Kenneth Russell <[email protected]> Reviewed-by: Shahbaz Youssefi <[email protected]> Reviewed-by: Geoff Lang <[email protected]> --- diff --git a/src/libANGLE/ErrorStrings.h b/src/libANGLE/ErrorStrings.h index 1ed3394..589a3da 100644 --- a/src/libANGLE/ErrorStrings.h +++ b/src/libANGLE/ErrorStrings.h @@ -676,6 +676,7 @@ inline constexpr const char *kWebGLNameLengthLimitExceeded = "Name is longer than %d characters."; inline constexpr const char *kYUVOutputMissmatch = "Program and framebuffer YUV output state does not match."; inline constexpr const char *kYUVTargetExtensionRequired = "GL_EXT_YUV_target not enabled."; +inline constexpr const char *kYuvTexSubImage2DOddOffsetOrDimension = "TexSubImage2D with YUV formats requires even offsets and dimensions."; inline constexpr const char *kZeroBoundToTarget = "Zero is bound to target."; inline constexpr const char *kUnrecognizedShaderStageBit = "Unrecognized shader stage bit."; inline constexpr const char *kProgramNotSeparable = "Program object was not linked with its PROGRAM_SEPARABLE status set."; diff --git a/src/libANGLE/renderer/vulkan/vk_helpers.cpp b/src/libANGLE/renderer/vulkan/vk_helpers.cpp index 56cfbda..42588e7 100644 --- a/src/libANGLE/renderer/vulkan/vk_helpers.cpp +++ b/src/libANGLE/renderer/vulkan/vk_helpers.cpp @@ -8507,15 +8507,25 @@ ASSERT(index.getLevelIndex() == 0); ASSERT(index.getLayerCount() == 1); + int hSub = 1, vSub = 1; + gl::GetSubSampleFactor(formatInfo.internalFormat, &hSub, &vSub); + for (uint32_t plane = 0; plane < yuvInfo.planeCount; plane++) { + gl::Offset planeOffset = offset; + if (plane > 0) + { + planeOffset.x = offset.x / hSub; + planeOffset.y = offset.y / vSub; + } + VkBufferImageCopy copy = {}; copy.bufferOffset = stagingOffset + yuvInfo.planeOffset[plane]; copy.bufferRowLength = 0; copy.bufferImageHeight = 0; copy.imageSubresource.mipLevel = 0; copy.imageSubresource.layerCount = 1; - gl_vk::GetOffset(offset, ©.imageOffset); + gl_vk::GetOffset(planeOffset, ©.imageOffset); gl_vk::GetExtent(yuvInfo.planeExtent[plane], ©.imageExtent); copy.imageSubresource.baseArrayLayer = 0; copy.imageSubresource.aspectMask = kPlaneAspectFlags[plane]; diff --git a/src/libANGLE/validationES3.cpp b/src/libANGLE/validationES3.cpp index c331225..ce0ac2f 100644 --- a/src/libANGLE/validationES3.cpp +++ b/src/libANGLE/validationES3.cpp @@ -716,6 +716,15 @@ return false; } + if (gl::IsYuvFormat(actualInternalFormat)) + { + if ((xoffset % 2) != 0 || (yoffset % 2) != 0 || (width % 2) != 0 || (height % 2) != 0) + { + ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kYuvTexSubImage2DOddOffsetOrDimension); + return false; + } + } + if (width > 0 && height > 0 && depth > 0 && pixels == nullptr && context->getState().getTargetBuffer(BufferBinding::PixelUnpack) == nullptr) { diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt index cc0f892..aaf5758 100644 --- a/src/tests/angle_end2end_tests_expectations.txt +++ b/src/tests/angle_end2end_tests_expectations.txt @@ -90,6 +90,7 @@ 42265137 VULKAN : UniformTest.UnusedStructInlineUniform/* = SKIP 42265137 VULKAN : UniformTest.UnusedStructInlineUniformWithSampler/* = SKIP 42265405 VULKAN : Texture2DTestES3.TextureRGBUpdateWithPBO/ES3_Vulkan* = SKIP +506193593 VULKAN : Texture2DTestES3.YUVTexSubImage2DNonZeroOffset_3Plane/* = SKIP 42265226 VULKAN : GLSLTest_ES3.GLVertexIDIntegerTextureDrawElementsU8Line/* = SKIP 42265226 VULKAN : GLSLTest_ES3.GLVertexIDIntegerTextureDrawElementsU8LineIds/* = SKIP 235877059 VULKAN : MultisampleTestES3.ResolveToFBO/ES3_Vulkan_EmulatedPrerotation180 = SKIP diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp index 5484dbb..c562a86 100644 --- a/src/tests/gl_tests/TextureTest.cpp +++ b/src/tests/gl_tests/TextureTest.cpp @@ -397,6 +397,7 @@ { protected: Texture2DTestES3() : Texture2DTest() {} + void runYUVTexSubImage2DNonZeroOffsetTest(GLenum format); const char *getVertexShaderSource() override { @@ -4916,6 +4917,138 @@ ASSERT_GL_NO_ERROR(); } +void Texture2DTestES3::runYUVTexSubImage2DNonZeroOffsetTest(GLenum format) +{ + ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_ANGLE_yuv_internal_format")); + + constexpr size_t kWidth = 4; + constexpr size_t kHeight = 4; + + struct YUVColor + { + GLubyte y; + GLubyte cb; + GLubyte cr; + }; + + // Color A: Y=0, Cb=128, Cr=128 -> RGB=(0, 0, 0) + constexpr YUVColor kColorA = {0, 128, 128}; + const GLColor kExpectedRgbA(0, 0, 0, 255); + + // Color B: Y=255, Cb=128, Cr=128 -> RGB=(255, 255, 255) + constexpr YUVColor kColorB = {255, 128, 128}; + const GLColor kExpectedRgbB(255, 255, 255, 255); + + std::vector<GLubyte> initData(24); + std::fill(initData.begin(), initData.begin() + 16, kColorA.y); // Y + if (format == GL_G8_B8R8_2PLANE_420_UNORM_ANGLE) + { + for (size_t i = 16; i < 24; i += 2) + { + initData[i] = kColorA.cb; + initData[i + 1] = kColorA.cr; + } + } + else + { + std::fill(initData.begin() + 16, initData.begin() + 20, kColorA.cb); + std::fill(initData.begin() + 20, initData.end(), kColorA.cr); + } + + GLTexture yuvTexture; + glBindTexture(GL_TEXTURE_2D, yuvTexture); + glTexStorage2D(GL_TEXTURE_2D, 1, format, kWidth, kHeight); + ASSERT_GL_NO_ERROR(); + + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kWidth, kHeight, format, GL_UNSIGNED_BYTE, + initData.data()); + ASSERT_GL_NO_ERROR(); + + // Update quadrant (2,2) with Color B + std::vector<GLubyte> updateData(6); + std::fill(updateData.begin(), updateData.begin() + 4, kColorB.y); // Y + updateData[4] = kColorB.cb; + updateData[5] = kColorB.cr; + + glTexSubImage2D(GL_TEXTURE_2D, 0, 2, 2, 2, 2, format, GL_UNSIGNED_BYTE, updateData.data()); + ASSERT_GL_NO_ERROR(); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + ASSERT_GL_NO_ERROR(); + + // Draw a quad with the target texture + glUseProgram(mProgram); + glBindTexture(GL_TEXTURE_2D, yuvTexture); + glUniform1i(mTexture2DUniformLocation, 0); + + drawQuad(mProgram, "position", 0.5f); + ASSERT_GL_NO_ERROR(); + + int w = getWindowWidth(); + int h = getWindowHeight(); + + // Bottom-left (Color A) + EXPECT_PIXEL_COLOR_NEAR(w / 4, h / 4, kExpectedRgbA, 1); + // Top-right (Color B) + EXPECT_PIXEL_COLOR_NEAR(3 * w / 4, 3 * h / 4, kExpectedRgbB, 1); + // Bottom-right (Color A) + EXPECT_PIXEL_COLOR_NEAR(3 * w / 4, h / 4, kExpectedRgbA, 1); + // Top-left (Color A) + EXPECT_PIXEL_COLOR_NEAR(w / 4, 3 * h / 4, kExpectedRgbA, 1); +} + +// Test sub-image uploads of data to ANGLE_yuv_internal_format 2-plane
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index cc0f892..aaf5758 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -90,6 +90,7 @@
42265137 VULKAN : UniformTest.UnusedStructInlineUniform/* = SKIP
42265137 VULKAN : UniformTest.UnusedStructInlineUniformWithSampler/* = SKIP
42265405 VULKAN : Texture2DTestES3.TextureRGBUpdateWithPBO/ES3_Vulkan* = SKIP
+506193593 VULKAN : Texture2DTestES3.YUVTexSubImage2DNonZeroOffset_3Plane/* = SKIP
42265226 VULKAN : GLSLTest_ES3.GLVertexIDIntegerTextureDrawElementsU8Line/* = SKIP
42265226 VULKAN : GLSLTest_ES3.GLVertexIDIntegerTextureDrawElementsU8LineIds/* = SKIP
235877059 VULKAN : MultisampleTestES3.ResolveToFBO/ES3_Vulkan_EmulatedPrerotation180 = SKIP
diff --git a/src/tests/gl_tests/TextureTest.cpp b/src/tests/gl_tests/TextureTest.cpp
index 5484dbb..c562a86 100644
--- a/src/tests/gl_tests/TextureTest.cpp
+++ b/src/tests/gl_tests/TextureTest.cpp
@@ -397,6 +397,7 @@
{
protected:
Texture2DTestES3() : Texture2DTest() {}
+ void runYUVTexSubImage2DNonZeroOffsetTest(GLenum format);
const char *getVertexShaderSource() override
{
@@ -4916,6 +4917,138 @@
ASSERT_GL_NO_ERROR();
}
+void Texture2DTestES3::runYUVTexSubImage2DNonZeroOffsetTest(GLenum format)
+{
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_ANGLE_yuv_internal_format"));
+
+ constexpr size_t kWidth = 4;
+ constexpr size_t kHeight = 4;
+
+ struct YUVColor
+ {
+ GLubyte y;
+ GLubyte cb;
+ GLubyte cr;
+ };
+
+ // Color A: Y=0, Cb=128, Cr=128 -> RGB=(0, 0, 0)
+ constexpr YUVColor kColorA = {0, 128, 128};
+ const GLColor kExpectedRgbA(0, 0, 0, 255);
+
+ // Color B: Y=255, Cb=128, Cr=128 -> RGB=(255, 255, 255)
+ constexpr YUVColor kColorB = {255, 128, 128};
+ const GLColor kExpectedRgbB(255, 255, 255, 255);
+
+ std::vector<GLubyte> initData(24);
+ std::fill(initData.begin(), initData.begin() + 16, kColorA.y); // Y
+ if (format == GL_G8_B8R8_2PLANE_420_UNORM_ANGLE)
+ {
+ for (size_t i = 16; i < 24; i += 2)
+ {
+ initData[i] = kColorA.cb;
+ initData[i + 1] = kColorA.cr;
+ }
+ }
+ else
+ {
+ std::fill(initData.begin() + 16, initData.begin() + 20, kColorA.cb);
+ std::fill(initData.begin() + 20, initData.end(), kColorA.cr);
+ }
+
+ GLTexture yuvTexture;
+ glBindTexture(GL_TEXTURE_2D, yuvTexture);
+ glTexStorage2D(GL_TEXTURE_2D, 1, format, kWidth, kHeight);
+ ASSERT_GL_NO_ERROR();
+
+ glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kWidth, kHeight, format, GL_UNSIGNED_BYTE,
+ initData.data());
+ ASSERT_GL_NO_ERROR();
+
+ // Update quadrant (2,2) with Color B
+ std::vector<GLubyte> updateData(6);
+ std::fill(updateData.begin(), updateData.begin() + 4, kColorB.y); // Y
+ updateData[4] = kColorB.cb;
+ updateData[5] = kColorB.cr;
+
+ glTexSubImage2D(GL_TEXTURE_2D, 0, 2, 2, 2, 2, format, GL_UNSIGNED_BYTE, updateData.data());
+ ASSERT_GL_NO_ERROR();
+
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+ glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+ ASSERT_GL_NO_ERROR();
+
+ // Draw a quad with the target texture
+ glUseProgram(mProgram);
+ glBindTexture(GL_TEXTURE_2D, yuvTexture);
+ glUniform1i(mTexture2DUniformLocation, 0);
+
+ drawQuad(mProgram, "position", 0.5f);
+ ASSERT_GL_NO_ERROR();
+
+ int w = getWindowWidth();
+ int h = getWindowHeight();
+
+ // Bottom-left (Color A)
+ EXPECT_PIXEL_COLOR_NEAR(w / 4, h / 4, kExpectedRgbA, 1);
+ // Top-right (Color B)
+ EXPECT_PIXEL_COLOR_NEAR(3 * w / 4, 3 * h / 4, kExpectedRgbB, 1);
+ // Bottom-right (Color A)
+ EXPECT_PIXEL_COLOR_NEAR(3 * w / 4, h / 4, kExpectedRgbA, 1);
+ // Top-left (Color A)
+ EXPECT_PIXEL_COLOR_NEAR(w / 4, 3 * h / 4, kExpectedRgbA, 1);
+}
+
+// Test sub-image uploads of data to ANGLE_yuv_internal_format 2-plane
+// textures with non-zero offsets.
+TEST_P(Texture2DTestES3, YUVTexSubImage2DNonZeroOffset_2Plane)
+{
+ runYUVTexSubImage2DNonZeroOffsetTest(GL_G8_B8R8_2PLANE_420_UNORM_ANGLE);
+}
+
+// Test sub-image uploads of data to ANGLE_yuv_internal_format 3-plane
+// textures with non-zero offsets.
+TEST_P(Texture2DTestES3, YUVTexSubImage2DNonZeroOffset_3Plane)
+{
+ runYUVTexSubImage2DNonZeroOffsetTest(GL_G8_B8_R8_3PLANE_420_UNORM_ANGLE);
+}
+
+// Verify newly added errors when uploading odd offsets and sizes to
+// ANGLE_yuv_internal_format textures.
+TEST_P(Texture2DTestES3, YUVTexSubImage2DInvalidOffsetsAndDimensions)
+{
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_ANGLE_yuv_internal_format"));
+
+ constexpr size_t kWidth = 4;
+ constexpr size_t kHeight = 4;
+
+ GLTexture yuvTexture;
+ glBindTexture(GL_TEXTURE_2D, yuvTexture);
+ glTexStorage2D(GL_TEXTURE_2D, 1, GL_G8_B8R8_2PLANE_420_UNORM_ANGLE, kWidth, kHeight);
+ ASSERT_GL_NO_ERROR();
+
+ std::vector<GLubyte> updateData(24, 0); // Size doesn't matter much for error generation
+
+ // Odd xoffset
+ glTexSubImage2D(GL_TEXTURE_2D, 0, 1, 0, 2, 2, GL_G8_B8R8_2PLANE_420_UNORM_ANGLE,
+ GL_UNSIGNED_BYTE, updateData.data());
+ EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+ // Odd yoffset
+ glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 1, 2, 2, GL_G8_B8R8_2PLANE_420_UNORM_ANGLE,
+ GL_UNSIGNED_BYTE, updateData.data());
+ EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+ // Odd width
+ glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 1, 2, GL_G8_B8R8_2PLANE_420_UNORM_ANGLE,
+ GL_UNSIGNED_BYTE, updateData.data());
+ EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+
+ // Odd height
+ glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 2, 1, GL_G8_B8R8_2PLANE_420_UNORM_ANGLE,
+ GL_UNSIGNED_BYTE, updateData.data());
+ EXPECT_GL_ERROR(GL_INVALID_OPERATION);
+}
+
// Test functionality of GL_ANGLE_yuv_internal_format glCopyTextureCHROMIUM
TEST_P(Texture2DTestES3YUV, CopyTextureChromium)
{
Original Bug Report
OOB GPU memory write in ANGLE Vulkan via unscaled YUV plane offsets
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 without the security team.
Overview: ANGLE’s Vulkan backend fails to scale offsets for subsampled chroma planes during YUV texture updates. A compromised renderer can bypass Luma-based validation to trigger a Vulkan OOB memory write. This enables a potential GPU sandbox escape.
Affected files:
third_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cppthird_party/angle/src/libANGLE/validationES3.cpp
Estimated timestamp from git blame: 2021-07-19
Final Impact
This vulnerability culminates in a confirmed out-of-bounds write of attacker-controlled data into adjacent VkDeviceMemory via a vkCmdCopyBufferToImage command, granting a compromised renderer process a direct path to code execution within the highly-privileged GPU process.
Vulnerability Details
Initial logic and parameters are validated. A compromised renderer process establishes a standard GLES3 context utilizing the Passthrough Command Decoder and successfully enables the GL_ANGLE_yuv_internal_format extension. When the attacker issues a glTexSubImage2D command for a YUV texture, the validation routine in ValidateES3TexImageParametersBase (third_party/angle/src/libANGLE/validationES3.cpp:705) evaluates the provided xoffset and yoffset against the overall texture dimensions (the full Luma plane).
From here, execution jumps directly to the final transformation in the Vulkan backend (ImageHelper::stageSubresourceUpdateImpl within vk_helpers.cpp). The logic iterates through the YUV planes and maps the parameters into a VkBufferImageCopy structure. For subsampled chroma planes, the code directly applies gl_vk::GetOffset(offset, ©.imageOffset). This injects the unscaled, luma-space offset into the command stream for a half-sized chroma plane. The Vulkan driver executes vkCmdCopyBufferToImage using an out-of-bounds destination coordinate, corrupting adjacent GPU heap memory.
Potential Reproduction Steps
(Note: These are suggested/potential steps as our tooling agent doesn’t yet have the ability to run code.)
- From a compromised renderer, initialize a GLES3 context via the passthrough command decoder and request the
GL_ANGLE_yuv_internal_formatextension. - Allocate a 1024x1024 4:2:0 YUV texture (e.g.,
GL_G8_B8R8_2PLANE_420_UNORM_ANGLE) usingglTexStorage2DEXT. - Issue a
glTexSubImage2Dcommand with an offset of 768 and dimensions of 256. Standard processing applies. - Validation passes because 768 + 256 <= 1024 (the luma plane dimension).
- The Vulkan backend stages the copy for Plane 1 (the 512x512 chroma plane) with an unscaled offset of 768, triggering the OOB write upon execution.
Suggested Fix
In third_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cpp within ImageHelper::stageSubresourceUpdateImpl, apply the correct subsampling division to the offset when computing copy.imageOffset for chroma planes, identical to how yuvInfo.planeExtent correctly scales the width and height.
Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.