CVE-2026-106304
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forsrc/tests/gl_tests/GLSLTest.cpp |
modified |
Files Changed
src/compiler/translator/ParseContext.cppsrc/compiler/translator/tree_util/IntermNode_util.hsrc/compiler/translator/tree_ops/ClampIndirectIndices.cppsrc/compiler/translator/tree_util/IntermNode_util.cppsrc/tests/angle_end2end_tests_expectations.txtsrc/tests/gl_tests/GLSLTest.cpp
Patch
From cac0efa537430c13c9f9ce391cd07545c3c7e153 Mon Sep 17 00:00:00 2001 From: Shahbaz Youssefi <[email protected]> Date: Wed, 02 Sep 2026 14:09:48 -0400 Subject: [PATCH] Translator: Fix gl_FragData index clamp vs comma If gl_FragData is hidden inside a comma expression, it was undetected in the ClampIndirectIndices AST transformation and its index was not clamped. Not an issue with IR. Bug: chromium:553124799 Change-Id: I343fc31a2aaf8e93aed598b92778e79cc911316a Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8342869 Reviewed-by: Yuxin Hu <[email protected]> Commit-Queue: Shahbaz Youssefi <[email protected]> --- diff --git a/src/compiler/translator/ParseContext.cpp b/src/compiler/translator/ParseContext.cpp index e4acc6e..9da86d5 100644 --- a/src/compiler/translator/ParseContext.cpp +++ b/src/compiler/translator/ParseContext.cpp @@ -533,23 +533,6 @@ components *= type.getArraySizeProduct(); return components; } - -TIntermTyped *RemoveCommaLeftHandSize(TIntermTyped *node) -{ - TIntermTyped *current = node; - while (true) - { - TIntermBinary *asBinary = current->getAsBinaryNode(); - if (asBinary == nullptr || asBinary->getOp() != EOpComma) - { - break; - } - - current = asBinary->getRight(); - } - - return current; -} } // namespace // This tracks each binding point's current default offset for inheritance of subsequent diff --git a/src/compiler/translator/tree_ops/ClampIndirectIndices.cpp b/src/compiler/translator/tree_ops/ClampIndirectIndices.cpp index 3872a33..00f8851 100644 --- a/src/compiler/translator/tree_ops/ClampIndirectIndices.cpp +++ b/src/compiler/translator/tree_ops/ClampIndirectIndices.cpp @@ -88,14 +88,16 @@ if (leftType.isArray()) { + const bool isFragData = + RemoveCommaLeftHandSize(node->getLeft())->getType().getQualifier() == EvqFragData; + int arraySize = static_cast<int>(leftType.getOutermostArraySize()); - if (leftType.getQualifier() == EvqFragData && - mExtDrawBuffers == ExtDrawBuffers::Disabled) + if (isFragData && mExtDrawBuffers == ExtDrawBuffers::Disabled) { // When EXT_draw_buffers is disabled, only element 0 of gl_FragData may be accessed. arraySize = 1; } - else if (leftType.getQualifier() == EvqFragData && mIsSecondaryFragDataUsed) + else if (isFragData && mIsSecondaryFragDataUsed) { // When gl_SecondaryFragDataEXT is used, only indices up to MaxDualSourceDrawBuffers // of gl_FragData may be accessed. diff --git a/src/compiler/translator/tree_util/IntermNode_util.cpp b/src/compiler/translator/tree_util/IntermNode_util.cpp index e8ea3c4..2d8fd35 100644 --- a/src/compiler/translator/tree_util/IntermNode_util.cpp +++ b/src/compiler/translator/tree_util/IntermNode_util.cpp @@ -571,6 +571,23 @@ return TIntermAggregate::CreateConstructor(castDestType, {scalar}); } +TIntermTyped *RemoveCommaLeftHandSize(TIntermTyped *node) +{ + TIntermTyped *current = node; + while (true) + { + TIntermBinary *asBinary = current->getAsBinaryNode(); + if (asBinary == nullptr || asBinary->getOp() != EOpComma) + { + break; + } + + current = asBinary->getRight(); + } + + return current; +} + void MoveDeclarationsBeforeFunctions(TIntermBlock *root) { TIntermSequence *original = root->getSequence(); diff --git a/src/compiler/translator/tree_util/IntermNode_util.h b/src/compiler/translator/tree_util/IntermNode_util.h index 88d9bcc..b57787c 100644 --- a/src/compiler/translator/tree_util/IntermNode_util.h +++ b/src/compiler/translator/tree_util/IntermNode_util.h @@ -172,6 +172,9 @@ // Cast a scalar to the basic type of type. No-ops if scalar is already the right type. TIntermNode *CastScalar(const TType &type, TIntermTyped *scalar); +// Given |(a, b, c, d)|, returns |d|. +TIntermTyped *RemoveCommaLeftHandSize(TIntermTyped *node); + // Called on root, moves declarations before function definitions. Done so that transformations // that need to do something with globals and append code for them in main() don't have to deal with // declarations after main. diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt index d24b4cf..1d8f85c 100644 --- a/src/tests/angle_end2end_tests_expectations.txt +++ b/src/tests/angle_end2end_tests_expectations.txt @@ -525,6 +525,7 @@ 534815900 MAC METAL : TextureCubeTestES3.RedefinedCubemapLevelsOnlyFaceZeroCompatible/* = SKIP 536936861 MAC METAL : ClipDistance*.ThreeClipDistancesRedeclaredAndPassedToFunction/* = SKIP 537814733 MAC METAL : WebGLGLSLTest.FragDataIndexClampWithoutDrawBuffers/* = SKIP +537814733 MAC METAL : WebGLGLSLTest.FragDataInCommaIndexClampWithoutDrawBuffers/* = SKIP // The workaround is not intended to be enabled in this configuration so // skip it as the failure is likely a driver bug. @@ -1328,6 +1329,7 @@ 535213600 PIXEL10 GLES : PbufferTestES3.BindTexImageAndCopyTextureInvertedSrc/* = SKIP 535213600 PIXEL10 GLES : PbufferTestES3.BindTexImageAndCopyTextureSrc/* = SKIP 537235694 PIXEL10 GLES : WebGLGLSLTest.FragDataIndexClampWithoutDrawBuffers/* = SKIP +537235694 PIXEL10 GLES : WebGLGLSLTest.FragDataInCommaIndexClampWithoutDrawBuffers/* = SKIP 546627192 PIXEL10 GLES : GLSLTest_ES3.VaryingAnonymousStructAssignment/* = SKIP 546627192 PIXEL10 GLES : GLSLTest_ES3.VaryingAnonymousStructTernary/* = SKIP @@ -2464,6 +2466,7 @@ 42267100 WGPU : GLSLTest.FragData/* = SKIP 42267100 WGPU : GLSLTest.FragData_AlphaToCoverage/* = SKIP 42267100 WGPU : WebGLGLSLTest.FragDataIndexClampWithoutDrawBuffers/* = SKIP +42267100 WGPU : WebGLGLSLTest.FragDataInCommaIndexClampWithoutDrawBuffers/* = SKIP 42267100 WGPU : GLSLTest.SeparateStructDeclaratorStructInStruct/* = SKIP 42267100 WGPU : GLSLTest.ConstructorinSequenceOperator/* = SKIP 42267100 WGPU : GLSLTest.VectorAndMatrixScalarizationDoesNotAffectRendering/* = SKIP diff --git a/src/tests/gl_tests/GLSLTest.cpp b/src/tests/gl_tests/GLSLTest.cpp index fbf7ec5..09a6d2e 100644 --- a/src/tests/gl_tests/GLSLTest.cpp +++ b/src/tests/gl_tests/GLSLTest.cpp @@ -24861,6 +24861,33 @@ } // Test that indirect indices to gl_FragData get clamped to the right bounds when +// GL_EXT_draw_buffers is not enabled, and gl_FragData is the right hand side of a comma expression. +// +// The same test for ES3 is not needed, because unlike gl_FragData in ESSL 100, it's not allowed to +// index a fragment output variable with a non-constant index in ESSL 300+. +TEST_P(WebGLGLSLTest, FragDataInCommaIndexClampWithoutDrawBuffers) +{ + constexpr char kFS[] = R"(precision mediump float; +void main() { + // GL_EXT_draw_buffers is not enabled, which means only one output is valid. Make sure all the + // following writes in the loop end up writing to gl_FragData[0]. + gl_FragData[0] = vec4(0.1, 0.05, 0, 1); + int v = 0; + vec4 res = vec4(0); + for (int i = 0; i < 8; i++) { + res += (v += 1, gl_FragData)[i]; + } + gl_FragData[0] = res; + gl_FragData[0].z = float(v == 8); +})"; + + ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), kFS); + drawQuad(program, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true); + EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(204, 102, 255, 255), 1); + ASSERT_GL_NO_ERROR(); +} + +// Test that indirect indices to gl_FragData get clamped to the right bounds when // gl_SecondaryFragDataEXT is used. // // The same test for ES3 is not needed, because unlike gl_FragData in ESSL 100, it's not allowed to @@ -25027,6 +25054,33 @@ ASSERT_GL_NO_ERROR(); } +// Test that indirect indices to gl_FragData get clamped to the right bounds when +// gl_SecondaryFragDataEXT is used, and gl_FragData is the right hand side of a comma expression. +TEST_P(WebGLGLSLTest, FragDataInCommaIndexClampWithSecondaryFragData) +{ + ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_draw_buffers")); + ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_blend_func_extended")); + + constexpr char kFS[] = R"(#extension GL_EXT_draw_buffers : require +#extension GL_EXT_blend_func_extended : require +void main() { + gl_FragData[0] = vec4(0.1, 0.05, 0, 1); + int v = 0; + mediump vec4 res = vec4(0); + for (int i = 0; i < 8; i++) { + res += (v += 1, gl_FragData)[i]; + } + gl_FragData[0] = res; + gl_FragData[0].z = float(v == 8); + gl_SecondaryFragDataEXT[0] = vec4(1.0, 0.0, 0.0, 1.0); +})"; + + ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), kFS); + drawQuad(program, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true); + EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(204, 102, 255, 255), 1); + ASSERT_GL_NO_ERROR(); +} + // Test that long symbols work TEST_P(GLSLTest_ES3, LongIdentifiers) {
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index d24b4cf..1d8f85c 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -525,6 +525,7 @@
534815900 MAC METAL : TextureCubeTestES3.RedefinedCubemapLevelsOnlyFaceZeroCompatible/* = SKIP
536936861 MAC METAL : ClipDistance*.ThreeClipDistancesRedeclaredAndPassedToFunction/* = SKIP
537814733 MAC METAL : WebGLGLSLTest.FragDataIndexClampWithoutDrawBuffers/* = SKIP
+537814733 MAC METAL : WebGLGLSLTest.FragDataInCommaIndexClampWithoutDrawBuffers/* = SKIP
// The workaround is not intended to be enabled in this configuration so
// skip it as the failure is likely a driver bug.
@@ -1328,6 +1329,7 @@
535213600 PIXEL10 GLES : PbufferTestES3.BindTexImageAndCopyTextureInvertedSrc/* = SKIP
535213600 PIXEL10 GLES : PbufferTestES3.BindTexImageAndCopyTextureSrc/* = SKIP
537235694 PIXEL10 GLES : WebGLGLSLTest.FragDataIndexClampWithoutDrawBuffers/* = SKIP
+537235694 PIXEL10 GLES : WebGLGLSLTest.FragDataInCommaIndexClampWithoutDrawBuffers/* = SKIP
546627192 PIXEL10 GLES : GLSLTest_ES3.VaryingAnonymousStructAssignment/* = SKIP
546627192 PIXEL10 GLES : GLSLTest_ES3.VaryingAnonymousStructTernary/* = SKIP
@@ -2464,6 +2466,7 @@
42267100 WGPU : GLSLTest.FragData/* = SKIP
42267100 WGPU : GLSLTest.FragData_AlphaToCoverage/* = SKIP
42267100 WGPU : WebGLGLSLTest.FragDataIndexClampWithoutDrawBuffers/* = SKIP
+42267100 WGPU : WebGLGLSLTest.FragDataInCommaIndexClampWithoutDrawBuffers/* = SKIP
42267100 WGPU : GLSLTest.SeparateStructDeclaratorStructInStruct/* = SKIP
42267100 WGPU : GLSLTest.ConstructorinSequenceOperator/* = SKIP
42267100 WGPU : GLSLTest.VectorAndMatrixScalarizationDoesNotAffectRendering/* = SKIP
diff --git a/src/tests/gl_tests/GLSLTest.cpp b/src/tests/gl_tests/GLSLTest.cpp
index fbf7ec5..09a6d2e 100644
--- a/src/tests/gl_tests/GLSLTest.cpp
+++ b/src/tests/gl_tests/GLSLTest.cpp
@@ -24861,6 +24861,33 @@
}
// Test that indirect indices to gl_FragData get clamped to the right bounds when
+// GL_EXT_draw_buffers is not enabled, and gl_FragData is the right hand side of a comma expression.
+//
+// The same test for ES3 is not needed, because unlike gl_FragData in ESSL 100, it's not allowed to
+// index a fragment output variable with a non-constant index in ESSL 300+.
+TEST_P(WebGLGLSLTest, FragDataInCommaIndexClampWithoutDrawBuffers)
+{
+ constexpr char kFS[] = R"(precision mediump float;
+void main() {
+ // GL_EXT_draw_buffers is not enabled, which means only one output is valid. Make sure all the
+ // following writes in the loop end up writing to gl_FragData[0].
+ gl_FragData[0] = vec4(0.1, 0.05, 0, 1);
+ int v = 0;
+ vec4 res = vec4(0);
+ for (int i = 0; i < 8; i++) {
+ res += (v += 1, gl_FragData)[i];
+ }
+ gl_FragData[0] = res;
+ gl_FragData[0].z = float(v == 8);
+})";
+
+ ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), kFS);
+ drawQuad(program, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true);
+ EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(204, 102, 255, 255), 1);
+ ASSERT_GL_NO_ERROR();
+}
+
+// Test that indirect indices to gl_FragData get clamped to the right bounds when
// gl_SecondaryFragDataEXT is used.
//
// The same test for ES3 is not needed, because unlike gl_FragData in ESSL 100, it's not allowed to
@@ -25027,6 +25054,33 @@
ASSERT_GL_NO_ERROR();
}
+// Test that indirect indices to gl_FragData get clamped to the right bounds when
+// gl_SecondaryFragDataEXT is used, and gl_FragData is the right hand side of a comma expression.
+TEST_P(WebGLGLSLTest, FragDataInCommaIndexClampWithSecondaryFragData)
+{
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_draw_buffers"));
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_blend_func_extended"));
+
+ constexpr char kFS[] = R"(#extension GL_EXT_draw_buffers : require
+#extension GL_EXT_blend_func_extended : require
+void main() {
+ gl_FragData[0] = vec4(0.1, 0.05, 0, 1);
+ int v = 0;
+ mediump vec4 res = vec4(0);
+ for (int i = 0; i < 8; i++) {
+ res += (v += 1, gl_FragData)[i];
+ }
+ gl_FragData[0] = res;
+ gl_FragData[0].z = float(v == 8);
+ gl_SecondaryFragDataEXT[0] = vec4(1.0, 0.0, 0.0, 1.0);
+})";
+
+ ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), kFS);
+ drawQuad(program, essl1_shaders::PositionAttrib(), 0.5f, 1.0f, true);
+ EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(204, 102, 255, 255), 1);
+ ASSERT_GL_NO_ERROR();
+}
+
// Test that long symbols work
TEST_P(GLSLTest_ES3, LongIdentifiers)
{
Original Bug Report
GPU OOB read via comma-wrapped gl_FragData bypassing ClampIndirectIndices in ANGLE
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: In WebGL1 (ESSL 100), wrapping gl_FragData in a comma expression sets the resulting AST binary node’s qualifier to EvqTemporary, bypassing the EvqFragData qualifier checks in ClampIndirectIndices. When dual-source blending (EXT_blend_func_extended) is enabled with gl_SecondaryFragDataEXT, TranslatorGLSL shrinks the emitted webgl_FragData output declaration to 1 element, but dynamic indirect indices are clamped up to MaxDrawBuffers - 1 (typically 7). Consequently, the emitted desktop GLSL shader performs dynamic out-of-bounds reads into the fragment output storage at GPU runtime.
Affected files:
third_party/angle/src/compiler/translator/tree_ops/ClampIndirectIndices.cppthird_party/angle/src/compiler/translator/glsl/TranslatorGLSL.cppthird_party/angle/src/compiler/translator/ParseContext.cpp
Estimated timestamp from git blame: 2026-06-23
Summary
A potential out-of-bounds read vulnerability exists in ANGLE’s GLSL translator when handling sequence (comma) expressions wrapping gl_FragData in WebGL1 shaders that use dual-source blending (EXT_blend_func_extended). When gl_FragData is indexed indirectly through a comma expression, ClampIndirectIndices fails to recognize the base qualifier as EvqFragData, leading to an emitted clamp ceiling of 7 against a declared array size of 1.
Note: These findings are based on static source-code analysis; our tooling agent does not have runtime execution capability, so the steps and outcomes described below represent potential reproduction and impact behaviors.
Affected Files
third_party/angle/src/compiler/translator/tree_ops/ClampIndirectIndices.cppthird_party/angle/src/compiler/translator/ParseContext.cppthird_party/angle/src/compiler/translator/glsl/TranslatorGLSL.cpp
Technical Details & Root Cause
-
Comma Qualifier Assignment: Under WebGL1 (
SH_WEBGL_SPEC), comma operations on arrays are permitted byTParseContext::addComma(third_party/angle/src/compiler/translator/ParseContext.cpp:9552-9562).TIntermBinary::CreateComma(IntermNode.cpp:1614-1621) sets the binary node’s type to the right operand (vec4[8]) but assigns its qualifier viaGetCommaQualifier()(IntermNode.cpp:1846-1857). When the left operand has side effects (e.g.,t = 1.0),GetCommaQualifierreturnsEvqTemporary, and constant folding is skipped (IntermNode.cpp:2220-2227). -
Bypassing Parse-time Constant Checks: When indexing dynamically (e.g., via a loop index
i),indexExpression->getAsConstantUnion()isnullptr(ParseContext.cpp:7334). Thus, the block updatingmMaxFragDataArrayIndexUsed(ParseContext.cpp:7486) is skipped, leaving it at0. The post-parse checkif (secondaryFragDataUsed && mMaxFragDataArrayIndexUsed >= mResources.MaxDualSourceDrawBuffers)(ParseContext.cpp:10797) passes because0 >= 1is false. -
Defect in ClampIndirectIndices:
ClampIndirectIndicesTraverser::visitBinary(ClampIndirectIndices.cpp:89-105) clamps indirect array indices based onleftType.getQualifier():if (leftType.isArray()) { int arraySize = static_cast<int>(leftType.getOutermostArraySize()); if (leftType.getQualifier() == EvqFragData && mExtDrawBuffers == ExtDrawBuffers::Disabled) { arraySize = 1; } else if (leftType.getQualifier() == EvqFragData && mIsSecondaryFragDataUsed) { arraySize = std::min(arraySize, mCompiler->getBuiltInResources().MaxDualSourceDrawBuffers); } max = createClampValue(arraySize - 1, useFloatClamp); }Because
leftTypeis retrieved directly fromnode->getLeft()without descending through comma expressions,leftType.getQualifier()isEvqTemporaryrather thanEvqFragData. The pass fails to shrinkarraySizetoMaxDualSourceDrawBuffers(1), resulting inmax = 7.0(MaxDrawBuffers - 1). -
Mismatched GLSL Emission:
TranslatorGLSL::writeOutputVariables(TranslatorGLSL.cpp:221-227) sees bothgl_FragDataandgl_SecondaryFragDataEXTinmOutputVariablesand emits:out vec4 webgl_FragData[1]; out vec4 webgl_SecondaryFragData[1];The body emits the index expression verbatim (
OutputGLSLBase.cpp:644-646, 694-697):acc += (t = 1.0, webgl_FragData)[int(clamp(float(i), 0.0, 7.0))];When executed, indices
1..7dynamically read past the 1-element allocation ofwebgl_FragDataon the GPU.
Potential Trigger Steps
- Run Chrome on desktop Linux or with
--use-angle=glusing the passthrough command decoder on a system with OpenGL >= 3.3. - In an unprivileged web page, create a WebGL1 context:
const gl = canvas.getContext('webgl');. - Request both extensions:
gl.getExtension('WEBGL_draw_buffers');andgl.getExtension('WEBGL_blend_func_extended');. - Compile a fragment shader containing a comma-wrapped dynamic read:
#extension GL_EXT_draw_buffers : require #extension GL_EXT_blend_func_extended : require precision mediump float; float t; void main() { vec4 acc = vec4(0.0); for (int i = 0; i < 8; ++i) { acc += (t = 1.0, gl_FragData)[i]; } gl_FragData[0] = acc + vec4(t); gl_SecondaryFragDataEXT[0] = vec4(0.25); } - Link the program and issue a draw call, then read back the accumulated result using
gl.readPixels(...).
Impact
This flaw allows a WebGL shader to bypass ANGLE’s mandatory array access clamping mechanism, resulting in dynamic out-of-bounds reads into fragment output storage during shader execution on desktop OpenGL drivers. The read data (which may include uninitialized GPU registers or stale cross-origin GPU memory) is accumulated into the fragment output and observable by web content via gl.readPixels().
Suggested Fix
In third_party/angle/src/compiler/translator/tree_ops/ClampIndirectIndices.cpp, unwrap sequence (EOpComma) expressions when evaluating leftType and its qualifier for indirect indexing nodes, ensuring the check descends to the effectively indexed expression (similar to RemoveCommaLeftHandSize in ParseContext.cpp):
const TIntermTyped *effectivelyIndexed = node->getLeft();
while (effectivelyIndexed->getAsBinaryNode() &&
effectivelyIndexed->getAsBinaryNode()->getOp() == EOpComma)
{
effectivelyIndexed = effectivelyIndexed->getAsBinaryNode()->getRight();
}
const TType &leftType = effectivelyIndexed->getType();
Evaluated with Chrome root at commit: c6718ebbf2c677c6f63691e28c7801c563f6b19c
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.