CVE-2026-9915
Overview
Background
- ANGLE Vulkan YUV textures
GL_ANGLE_yuv_internal_formatmulti-planar formats (e.g.GL_G8_B8R8_2PLANE_420_UNORM_ANGLE) store luma (plane 0) at full resolution and chroma (planes 1+) at reduced (4:2:0 → half) resolution.- `glTexSubImage2D` on YUV
- Uploads to a sub-rectangle given by
xoffset/yoffset/width/heightin luma coordinates; each plane must be addressed in its own (possibly subsampled) coordinate space. - `yuvInfo.planeExtent[plane]` / `gl::GetSubSampleFactor`
- ANGLE already scales each plane’s extent via
planeExtent;GetSubSampleFactor(internalFormat, &hSub, &vSub)returns the horizontal/vertical chroma subsampling factors.
Root Cause Analysis
When staging a YUV sub-image upload, ImageHelper::stageSubresourceUpdateImpl iterated over yuvInfo.planeCount and set each plane’s VkBufferImageCopy with the extent correctly scaled per plane (yuvInfo.planeExtent[plane]) but applied the destination offset via gl_vk::GetOffset(offset, ©.imageOffset) using the raw, unscaled luma-space offset for every plane. For a subsampled chroma plane (half the luma dimensions), an offset that is valid in luma space (validated against the full luma dimensions) lands out of bounds in the half-sized chroma image. The front-end validation in ValidateES3TexImageParametersBase only checks xoffset + width <= textureWidth etc. against the luma plane, so e.g. offset=768, width=256 passes (768+256 ≤ 1024) while the chroma plane is only 512 wide — the copy’s destination coordinate 768 is past the chroma plane’s extent. The driver then executes vkCmdCopyBufferToImage writing attacker-controlled bytes out of bounds into adjacent VkDeviceMemory.
The patch fixes both halves. In vk_helpers.cpp, before the per-plane loop it computes int hSub = 1, vSub = 1; gl::GetSubSampleFactor(formatInfo.internalFormat, &hSub, &vSub);, and inside the loop builds gl::Offset planeOffset = offset; scaling it for non-luma planes (if (plane > 0) { planeOffset.x = offset.x / hSub; planeOffset.y = offset.y / vSub; }), then passes planeOffset to gl_vk::GetOffset(...). Now the destination offset is in each plane’s own coordinate space, consistent with the already-scaled planeExtent. In validationES3.cpp, a defense-in-depth check rejects odd offsets/dimensions for YUV formats: if (gl::IsYuvFormat(actualInternalFormat)) { if ((xoffset%2)||(yoffset%2)||(width%2)||(height%2)) ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, kYuvTexSubImage2DOddOffsetOrDimension); }, since odd values would not divide cleanly by the chroma subsample factor. The advisory’s OOB/heap-buffer-overflow classification is corroborated directly by the diff (unscaled offset → out-of-bounds imageOffset → OOB write).
Attack Path
- Enable extension
From a renderer using the passthrough command decoder, initialize a GLES3 context and enable
GL_ANGLE_yuv_internal_format. - Allocate YUV texture
glTexStorage2DEXTa 1024x1024 4:2:0 texture (e.g.GL_G8_B8R8_2PLANE_420_UNORM_ANGLE). - Craft sub-image
Issue
glTexSubImage2Dwithxoffset/yoffset = 768,width/height = 256, so768 + 256 ≤ 1024passes the luma-plane validation. - Stage with unscaled offset
stageSubresourceUpdateImplstages the chroma plane (512x512) with the raw luma offset768, past the chroma extent. - OOB write
vkCmdCopyBufferToImageexecutes with the out-of-bounds destination offset, writing attacker-controlled upload bytes into adjacentVkDeviceMemory— a GPU-process heap OOB write and a path toward code execution in the GPU process.
Impact Assessment
S:C, crossing into the GPU process/driver). The primitive is an out-of-bounds write of attacker-controlled data into GPU device memory adjacent to the chroma plane, via a validly-validated-but-mis-mapped vkCmdCopyBufferToImage. This is a strong corruption primitive (controlled data, controlled offset magnitude) consistent with the advisory’s heap-buffer-overflow label; escalation to GPU-process code execution is (inferred). Gated on the YUV internal-format extension and a 4:2:0 (subsampled) format; the odd-offset validation additionally constrains the exploit to even coordinates.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
Audit Directions
- Per-plane coordinate scalingAudit all Vulkan-backend YUV paths (uploads, copies, blits, readbacks) for offsets/regions applied in luma space to subsampled chroma planes without dividing by the subsample factor.
- Validation vs. backend coordinate spaceReview other formats/operations where front-end validation uses the base/luma dimensions while the backend addresses a differently-sized plane or mip.
- Odd-dimension handlingCheck that the new even-offset/dimension rule is enforced consistently across
glTexSubImage3D,glCopyTexSubImage*, and 3-plane formats (the 3-plane sub-image test is skipped per the expectations change), where rendering correctness is noted as untested.
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.