CVE-2026-87500
Overview
Background
- ANGLE
- Chrome’s shader translator/validation layer that compiles WebGL ESSL/GLSL into the platform’s native graphics API.
- `EOpComma`
- the AST operator node for GLSL’s comma operator, whose value and qualifier come from its right-hand operand (
asBinary->getRight()). - Qualifier (`getQualifier`)
- a
TIntermTypednode’s storage/usage classification (e.g.EvqFragData,EvqFragmentOut,EvqPerVertexIn) that ANGLE uses to gate indexing restrictions. - `TParseContext`
- ANGLE’s parse-time context in
ParseContext.cppthat validatesexpression[index]accesses and reports errors for illegal or out-of-range indices.
Root Cause Analysis
When validating an expression[index] access, the index-handling code in ParseContext.cpp gated every restriction directly on baseExpression->getQualifier(), baseExpression->isArray(), baseExpression->isMatrix(), and related type queries. The invariant it assumed was that baseExpression is the actual indexed object, but a comma expression such as (x, gl_SomeBuiltIn) produces an EOpComma binary node whose own qualifier is the generic result qualifier rather than EvqFragData/EvqFragmentOut/etc. of its right-hand side.
As a result, wrapping a restricted built-in in a comma operator caused all the qualifier-gated checks — the “index must be constant” rules for fragment outputs and gl_FragData, and the out-of-range checkIndexLessThan bound clamping — to be silently skipped.
The fix adds RemoveCommaLeftHandSize, which walks down the right-hand operand of any chained EOpComma nodes to obtain the truly indexed expression, and then performs every qualifier/array/matrix/vector check against that effectivelyIndexedExpression instead of the raw baseExpression. This restores the invariant that (x, gl_SomeBuiltIn)[index] is subject to the identical restrictions as gl_SomeBuiltIn[index].
Attack Path
- Craft a comma-wrapped index
In a WebGL shader, write an indexed access whose base is a comma expression, e.g.
(0, gl_FragData)[i]or(x, someIoBlock)[i], so the base AST node is anEOpCommarather than the built-in itself. - Bypass the constant-index gate
Because
getQualifier()on the comma node is notEvqFragData/EvqFragmentOut/EvqLastFragData, the “array indexes must be constant” errors never fire, allowing a dynamicindex. - Bypass the out-of-range clamp
For the same reason
isArray()/isMatrix()/isVector()checks against the comma node skipcheckIndexLessThan, soindexis not clamped to the object’s true size. - Emit unvalidated translated shader ANGLE passes the shader through to the backend translator without the safety rewrite, leaving an unbounded or restriction-violating index in the generated native shader.
Impact Assessment
Files Changed
src/compiler/translator/ParseContext.cpp
Audit Directions
- Comma-operator normalizationAudit every site that inspects
getQualifier(),isArray(), or type shape of an indexed or operandTIntermTypedfor whether anEOpCommawrapper could hide the real node; such checks should first strip comma left-hand sides. - Qualifier-gated validationReview other restrictions keyed on specific qualifiers (
EvqPerVertexIn,EvqPatchIn/EvqPatchOut, shader I/O blocks,EvqSecondaryFragDataEXT) to confirm they cannot be evaded by ternary, comma, or other value-forwarding expressions. - Base-expression identity assumptionsLook for translator code that assumes a syntactic
baseExpressionis the semantic object being indexed and add explicit normalization before bounds and constant-index enforcement.
Patch
From e422fa19258e4dfa2b56cd7a9185cae7a36cd502 Mon Sep 17 00:00:00 2001 From: Shahbaz Youssefi <[email protected]> Date: Fri, 31 Jul 2026 00:21:39 -0400 Subject: [PATCH] Translator: Fix index-checks vs comma In `expression[index]`, when checking if certain limitations apply to the `expression` and `index`, skip all comma operator left-hand sides in `expression` before checking the qualifier. If for example `gl_SomeBuiltIn[index]` has a restriction, so does `(x, gl_SomeBuiltIn)[index]`. Bug: chromium:540019091 Change-Id: Ib01937521eba275220f7e157686606cda53331f2 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8178173 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 9abfb33..0b17aaf 100644 --- a/src/compiler/translator/ParseContext.cpp +++ b/src/compiler/translator/ParseContext.cpp @@ -529,25 +529,21 @@ return components; } -bool IsWholeArrayFragDataUsed(TIntermTyped *node) +TIntermTyped *RemoveCommaLeftHandSize(TIntermTyped *node) { - if (node->getQualifier() == EvqFragData) + TIntermTyped *current = node; + while (true) { - return true; + TIntermBinary *asBinary = current->getAsBinaryNode(); + if (asBinary == nullptr || asBinary->getOp() != EOpComma) + { + break; + } + + current = asBinary->getRight(); } - TIntermBinary *asBinary = node->getAsBinaryNode(); - if (asBinary != nullptr && asBinary->getOp() == EOpComma) - { - return IsWholeArrayFragDataUsed(asBinary->getRight()); - } - - // Either this is not ESSL 100 (where gl_FragData may be used), or gl_FragData is not used as a - // whole array. - // - // Note: ESSL 100 does not allow arrays in ternary operator, so there is no need to check for - // TIntermTernary here for a whole-array use of gl_FragData. - return false; + return current; } } // namespace @@ -3395,7 +3391,10 @@ for (size_t i = 0; i < fnCandidate->getParamCount(); ++i) { TIntermTyped *argument = (*fnCall->getSequence())[i]->getAsTyped(); - if (IsWholeArrayFragDataUsed(argument)) + // Note: ESSL 100 does not allow arrays in ternary operator, so there is no need to check + // for TIntermTernary here for a whole-array use of gl_FragData, only descending into + // EOpComma nodes is sufficient. + if (RemoveCommaLeftHandSize(argument)->getQualifier() == EvqFragData) { // The whole array is passed to the function. For validation purposes, assume all // indices are accessed in the function. @@ -7307,7 +7306,8 @@ return CreateZeroNode(TType(EbtFloat, EbpHigh, EvqConst)); } - switch (baseExpression->getQualifier()) + TIntermTyped *effectivelyIndexedExpression = RemoveCommaLeftHandSize(baseExpression); + switch (effectivelyIndexedExpression->getQualifier()) { case EvqPerVertexIn: if (mGeometryShaderInputPrimitiveType == EptUndefined && @@ -7341,9 +7341,9 @@ // effects - like array length() method on a non-constant array. if (indexExpression->getQualifier() != EvqConst || indexConstantUnion == nullptr) { - if (baseExpression->isInterfaceBlock()) + if (effectivelyIndexedExpression->isInterfaceBlock()) { - switch (baseExpression->getQualifier()) + switch (effectivelyIndexedExpression->getQualifier()) { case EvqPerVertexIn: break; @@ -7364,9 +7364,9 @@ break; default: // It's ok for shader I/O blocks to be dynamically indexed - if (!IsShaderIoBlock(baseExpression->getQualifier()) && - baseExpression->getQualifier() != EvqPatchIn && - baseExpression->getQualifier() != EvqPatchOut) + if (!IsShaderIoBlock(effectivelyIndexedExpression->getQualifier()) && + effectivelyIndexedExpression->getQualifier() != EvqPatchIn && + effectivelyIndexedExpression->getQualifier() != EvqPatchOut) { // We can reach here only in error cases. ASSERT(mDiagnostics->numErrors() > 0); @@ -7374,29 +7374,30 @@ break; } } - else if (baseExpression->getQualifier() == EvqFragmentOut || - baseExpression->getQualifier() == EvqFragmentInOut) + else if (effectivelyIndexedExpression->getQualifier() == EvqFragmentOut || + effectivelyIndexedExpression->getQualifier() == EvqFragmentInOut) { error(location, "array indexes for fragment outputs must be constant integral expressions", "["); } - else if (baseExpression->getQualifier() == EvqLastFragData) + else if (effectivelyIndexedExpression->getQualifier() == EvqLastFragData) { error(location, "array indexes for gl_LastFragData must be constant integral expressions", "["); } - else if (mShaderSpec == SH_WEBGL2_SPEC && baseExpression->getQualifier() == EvqFragData) + else if (mShaderSpec == SH_WEBGL2_SPEC && + effectivelyIndexedExpression->getQualifier() == EvqFragData) { error(location, "array index for gl_FragData must be constant zero", "["); } else if (mShaderSpec == SH_WEBGL2_SPEC && - baseExpression->getQualifier() == EvqSecondaryFragDataEXT) + effectivelyIndexedExpression->getQualifier() == EvqSecondaryFragDataEXT) { error(location, "array index for gl_SecondaryFragDataEXT must be constant zero", "["); } - else if (baseExpression->isArray()) + else if (effectivelyIndexedExpression->isArray()) { - TBasicType elementType = baseExpression->getType().getBasicType(); + TBasicType elementType = effectivelyIndexedExpression->getType().getBasicType(); // Note: In Section 12.30 of the ESSL 3.00 spec on p143-144: // @@ -7473,9 +7474,10 @@ safeIndex = 0; } - if (!baseExpression->getType().isUnsizedArray()) + if (!effectivelyIndexedExpression->getType().isUnsizedArray()) { - if (baseExpression->isArray() && baseExpression->getQualifier() == EvqFragData) + if (effectivelyIndexedExpression->isArray() && + effectivelyIndexedExpression->getQualifier() == EvqFragData) { mMaxFragDataArrayIndexUsed = std::max(mMaxFragDataArrayIndexUsed, index); if (index > 0 && !isExtensionEnabled(TExtension::EXT_draw_buffers)) @@ -7490,24 +7492,27 @@ // Only do generic out-of-range check if similar error hasn't already been reported. if (safeIndex < 0) { - if (baseExpression->isArray()) + if (effectivelyIndexedExpression->isArray()) { - safeIndex = checkIndexLessThan(outOfRangeIndexIsError, location, index, - baseExpression->getOutermostArraySize(), - "array index out of range"); + safeIndex = + checkIndexLessThan(outOfRangeIndexIsError, location, index, + effectivelyIndexedExpression->getOutermostArraySize(), + "array index out of range"); } - else if (baseExpression->isMatrix()) + else if (effectivelyIndexedExpression->isMatrix()) { - safeIndex = checkIndexLessThan(outOfRangeIndexIsError, location, index, - baseExpression->getType().getCols(), - "matrix field selection out of range"); + safeIndex = + checkIndexLessThan(outOfRangeIndexIsError, location, index, + effectivelyIndexedExpression->getType().getCols(), + "matrix field selection out of range"); } else { - ASSERT(baseExpression->isVector()); - safeIndex = checkIndexLessThan(outOfRangeIndexIsError, location, index, - baseExpression->getType().getNominalSize(), - "vector field selection out of range"); + ASSERT(effectivelyIndexedExpression->isVector()); + safeIndex = + checkIndexLessThan(outOfRangeIndexIsError, location, index, + effectivelyIndexedExpression->getType().getNominalSize(), + "vector field selection out of range"); } } @@ -7527,7 +7532,8 @@ new TIntermBinary(EOpIndexDirect, baseExpression, indexExpression); node->setLine(location);
Regression Test / PoC
diff --git a/src/tests/gl_tests/GLSLValidationTest.cpp b/src/tests/gl_tests/GLSLValidationTest.cpp
index b7240c7..cdc585f 100644
--- a/src/tests/gl_tests/GLSLValidationTest.cpp
+++ b/src/tests/gl_tests/GLSLValidationTest.cpp
@@ -1180,12 +1180,12 @@
TEST_P(GLSLValidationTest_ES3, EmptyArrayConstructor)
{
constexpr char kFS[] = R"(#version 300 es
- precision mediump float;
- out vec4 my_FragColor;
- uniform float u;
- const float[] f = float[]();
- void main() {
- my_FragColor = vec4(0.0);
+ precision mediump float;
+ out vec4 my_FragColor;
+ uniform float u;
+ const float[] f = float[]();
+ void main() {
+ my_FragColor = vec4(0.0);
})";
validateError(GL_FRAGMENT_SHADER, kFS,
@@ -1197,13 +1197,33 @@
TEST_P(GLSLValidationTest_ES3, DynamicallyIndexedFragmentOutput)
{
constexpr char kFS[] = R"(#version 300 es
- precision mediump float;
- uniform int a;
- out vec4[2] my_FragData;
- void main()
- {
+ precision mediump float;
+ uniform int a;
+ out vec4[2] my_FragData;
+ void main()
+ {
my_FragData[true ? 0 : a] = vec4(0.0);
- }
+ }
+ )";
+
+ validateError(
+ GL_FRAGMENT_SHADER, kFS,
+ " '[' : array indexes for fragment outputs must be constant integral expressions");
+}
+
+// Test that indexing fragment outputs with a non-constant expression is forbidden, even if ANGLE
+// is able to constant fold the index expression. ESSL 3.00 section 4.3.6.
+TEST_P(GLSLValidationTest_ES3, DynamicallyIndexedFragmentOutput2)
+{
+ constexpr char kFS[] = R"(#version 300 es
+ precision mediump float;
+ uniform int a;
+ out vec4[2] my_FragData;
+ void main()
+ {
+ float unused;
+ (unused, my_FragData)[true ? 0 : a];
+ }
)";
validateError(
@@ -1268,7 +1288,8 @@
out vec4 my_FragColor;
void main()
{
- my_FragColor = texture(s[true ? 0 : a], vec2(0));
+ float unused;
+ my_FragColor = texture((unused, s)[true ? 0 : a], vec2(0));
})";
validateError(GL_FRAGMENT_SHADER, kFS,
@@ -1286,7 +1307,8 @@
out vec4 my_FragColor;
void main()
{
- my_FragColor = imageLoad(image[true ? 0 : a], ivec2(0));
+ float unused;
+ my_FragColor = imageLoad((unused, unused, image)[true ? 0 : a], ivec2(0));
})";
validateError(GL_FRAGMENT_SHADER, kFS,
@@ -5035,6 +5057,29 @@
"GL_MAX_DUAL_SOURCE_DRAW_BUFFERS_EXT when gl_SecondaryFragDataEXT is used");
}
+// Shader that writes to SecondaryFragData and FragData at an index >= than
+// gl_MaxDualSourceDrawBuffersEXT. FragData is the result of a comma operator.
+TEST_P(GLSLValidationTest, BlendFuncExtendedDataArrayAndSecondaryDataWithComma)
+{
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_blend_func_extended"));
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_draw_buffers"));
+
+ constexpr char kFS[] = R"(#extension GL_EXT_draw_buffers : require
+#extension GL_EXT_blend_func_extended : require
+precision mediump float;
+void main() {
+ float f;
+ vec4 value = (f = 0.,
+ gl_SecondaryFragDataEXT[0] = vec4(1.0),
+ f += 0.5,
+ gl_FragData)[gl_MaxDualSourceDrawBuffersEXT];
+ gl_FragData[0] = vec4(value.xyz, f);
+})";
+ validateError(GL_FRAGMENT_SHADER, kFS,
+ "array index for gl_FragData must be less than "
+ "GL_MAX_DUAL_SOURCE_DRAW_BUFFERS_EXT when gl_SecondaryFragDataEXT is used");
+}
+
// Shader that writes to SecondaryFragData and passes FragData to a function.
TEST_P(GLSLValidationTest, BlendFuncExtendedPassFragDataToFunction)
{
@@ -6997,10 +7042,10 @@
ANGLE_SKIP_TEST_IF(maxCombinedClipAndCullDistances > 11);
constexpr char kFS[] = R"(out highp vec4 fragColor;
-
void main()
{
- fragColor = vec4(gl_ClipDistance[4], gl_CullDistance[5], 0, 1);
+ mediump float unused;
+ fragColor = vec4((unused, gl_ClipDistance)[4], (unused, unused, gl_CullDistance)[5], 0, 1);
})";
constexpr char kExpect[] =
"The sum of 'gl_ClipDistance' and 'gl_CullDistance' size is greater than "
@@ -7250,6 +7295,41 @@
}
}
+// Shader redeclares gl_ClipDistance, but after it's been referenced with a non-constant index.
+TEST_P(GLSLValidationClipDistanceTest_ES3, RedeclareClipDistanceAfterReferenceNonConstantIndex2)
+{
+ const bool hasExt = IsGLExtensionEnabled("GL_EXT_clip_cull_distance");
+ const bool hasAngle = IsGLExtensionEnabled("GL_ANGLE_clip_cull_distance");
+ ANGLE_SKIP_TEST_IF(!hasExt && !hasAngle);
+
+ constexpr char kVS[] =
+ R"(in vec4 aPosition;
+void main()
+{
+ gl_Position = aPosition;
+ for (int i = 0; i < 2; ++i)
+ {
+ float unused;
+ float f = (unused, gl_ClipDistance)[i];
+ }
+}
+out highp float gl_ClipDistance[3];
+)";
+ constexpr char kExpect[] =
+ "'gl_ClipDistance' : redeclaration of gl_ClipDistance after it is referenced is not "
+ "allowed";
+
+ if (hasAngle)
+ {
+ validateErrorWithExt(GL_VERTEX_SHADER, "GL_ANGLE_clip_cull_distance", kVS, kExpect);
+ }
+
+ if (hasExt)
+ {
+ validateErrorWithExt(GL_VERTEX_SHADER, "GL_EXT_clip_cull_distance", kVS, kExpect);
+ }
+}
+
// Shader redeclares gl_CullDistance, but after it's been referenced with a non-constant index.
TEST_P(GLSLValidationClipDistanceTest_ES3, RedeclareCullDistanceAfterReferenceNonConstantIndex)
{
Original Bug Report
Potential gl_FragData indexing bypass via comma operator leading to OOB SPIR-V generation
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: A potential vulnerability in ANGLE allows bypassing array-bounds checking and index-clamping for gl_FragData using a comma operator. This results in the generation of malformed SPIR-V containing an out-of-bounds OpAccessChain. This issue could potentially lead to driver-level memory corruption in the GPU process.
Affected files:
third_party/angle/src/compiler/translator/ParseContext.cppthird_party/angle/src/compiler/translator/tree_ops/spirv/EmulateFragColorData.cppthird_party/angle/src/compiler/translator/IntermNode.cppthird_party/angle/src/compiler/translator/spirv/OutputSPIRV.cpp
Estimated timestamp from git blame: 2026-04-27
Potential out-of-bounds gl_FragData indexing bypass via comma operator
Root Cause Analysis
In third_party/angle/src/compiler/translator/ParseContext.cpp, TParseContext::addIndexExpression performs bounds tracking and safety-clamping for gl_FragData only when the immediate baseExpression has the qualifier EvqFragData:
// third_party/angle/src/compiler/translator/ParseContext.cpp
if (!baseExpression->getType().isUnsizedArray())
{
if (baseExpression->isArray() && baseExpression->getQualifier() == EvqFragData)
{
mMaxFragDataArrayIndexUsed = std::max(mMaxFragDataArrayIndexUsed, index);
if (index > 0 && !isExtensionEnabled(TExtension::EXT_draw_buffers))
{
outOfRangeError(...);
safeIndex = 0;
}
}
When gl_FragData is wrapped inside a sequence/comma operator—such as (sideEffect, gl_FragData)[7]—the qualifier of the resulting EOpComma node is evaluated as EvqTemporary (via GetCommaQualifier in IntermNode.cpp:1846). This causes the compiler to completely skip the tracking and clamping logic shown above.
Because the generic bounds check at ParseContext.cpp:7481 checks against the comma node’s promoted outermost array size (which copies the type of its right operand gl_FragData, e.g., vec4[8]), a constant index such as 7 is successfully validated and retained.
Subsequently, during translation inside the Vulkan/SPIR-V backend, EmulateFragColorData (tree_ops/spirv/EmulateFragColorData.cpp:76-99) shrinks the output array to mResources.MaxDualSourceDrawBuffers (which is 1 when gl_SecondaryFragDataEXT is statically used). However, because TIntermBinary::replaceChildNode is used to replace the symbol without re-promoting parent node types, the parent nodes still assume a size of 8. Consequently, the code generator emits a direct OpAccessChain with the constant index 7 into an Output array declared with size 1.
This produces malformed SPIR-V that violates Vulkan’s Valid Usage requirements. Since Vulkan’s robustBufferAccess does not cover the Output storage class, this can trigger driver-defined behavior or memory corruption in the GPU process.
Potential Steps to Reproduce
Note: These are suggested/potential steps to reproduce. Our tooling agent does not currently have the capability to execute code or verify the exploitability of this issue on a live target.
- Initialize a standard WebGL1 context on a page:
const gl = canvas.getContext('webgl'); - Enable the required extensions (both stable and non-privileged):
gl.getExtension('WEBGL_draw_buffers'); gl.getExtension('WEBGL_blend_func_extended'); - Submit and compile a fragment shader containing a comma-wrapped access into
gl_FragDatacombined with dual-source blending, for example:#extension GL_EXT_blend_func_extended : require precision mediump float; void main() { gl_SecondaryFragDataEXT[0] = vec4(0.5); vec4 c = (gl_SecondaryFragDataEXT[0] = vec4(0.5), gl_FragData)[7]; gl_FragData[0] = c; } - Link the program and execute a draw call. This triggers the generation of malformed SPIR-V inside the GPU process, potentially resulting in memory corruption within the platform graphics driver.
Suggested Fix
To remediate this issue, update TParseContext::addIndexExpression in ParseContext.cpp to recursively traverse through any sequence/comma operator nodes on the baseExpression to locate the underlying operand. This ensures that indexing constraints on gl_FragData (or similar built-ins) are correctly applied even when wrapped in a comma operator, matching the pattern already used in IsWholeArrayFragDataUsed:
TIntermTyped *realBase = baseExpression;
while (realBase && realBase->getAsBinaryNode() && realBase->getAsBinaryNode()->getOp() == EOpComma)
{
realBase = realBase->getAsBinaryNode()->getRight();
}
if (realBase && realBase->isArray() && realBase->getQualifier() == EvqFragData)
{
// ... perform tracking and safety-clamping on the underlying gl_FragData ...
}
Evaluated with Chrome root at commit: 94d9235ebe3b7276e5284f0dc5d55577ff949908
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.