CVE-2026-17801
Overview
Files Changed
src/compiler/translator/ParseContext.cppsrc/compiler/translator/SymbolTable.cppsrc/compiler/translator/SymbolTable.hsrc/compiler/translator/ir/src/compile.rssrc/compiler/translator/ir/src/ir.rssrc/compiler/translator/ir/src/transform/initialize_uninitialized_variables.rs
Patch
From 74622ea206bd671b40fc9c2c9c4f3c1666274604 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Thu, 11 Jun 2026 18:35:19 -0400
Subject: [PATCH] Fix clamping indices to gl_FragData vs dual source blending
Bug: chromium:514482938
Change-Id: If96682b8cf5c514f2587ad887a9a2b7ab1376dc5
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7926995
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Reviewed-by: Yuxin Hu <yuxinhu@google.com>
---
diff --git a/src/compiler/translator/ParseContext.cpp b/src/compiler/translator/ParseContext.cpp
index c4a8c94..ef293f8 100644
--- a/src/compiler/translator/ParseContext.cpp
+++ b/src/compiler/translator/ParseContext.cpp
@@ -357,10 +357,7 @@
{
usesFragColor = true;
}
- // Extension variables may not always be initialized (saves some time at symbol table init).
- bool secondaryFragDataUsed =
- symbolTable.gl_SecondaryFragDataEXT() != nullptr &&
- symbolTable.isStaticallyUsed(*symbolTable.gl_SecondaryFragDataEXT());
+ const bool secondaryFragDataUsed = symbolTable.isSecondaryFragDataUsed();
if (symbolTable.isStaticallyUsed(*symbolTable.gl_FragData()) || secondaryFragDataUsed)
{
usesFragData = true;
diff --git a/src/compiler/translator/SymbolTable.cpp b/src/compiler/translator/SymbolTable.cpp
index c280656..c728662 100644
--- a/src/compiler/translator/SymbolTable.cpp
+++ b/src/compiler/translator/SymbolTable.cpp
@@ -247,6 +247,12 @@
return static_cast<const TVariable *>(m_gl_SecondaryFragDataEXT);
}
+bool TSymbolTable::isSecondaryFragDataUsed() const
+{
+ // Extension variables may not always be initialized (saves some time at symbol table init).
+ return gl_SecondaryFragDataEXT() != nullptr && isStaticallyUsed(*gl_SecondaryFragDataEXT());
+}
+
TSymbolTable::VariableMetadata *TSymbolTable::getOrCreateVariableMetadata(const TVariable &variable)
{
int id = variable.uniqueId().get();
diff --git a/src/compiler/translator/SymbolTable.h b/src/compiler/translator/SymbolTable.h
index a196c3b..f4a58ee 100644
--- a/src/compiler/translator/SymbolTable.h
+++ b/src/compiler/translator/SymbolTable.h
@@ -240,6 +240,7 @@
const TVariable *gl_FragData() const;
const TVariable *gl_SecondaryFragDataEXT() const;
+ bool isSecondaryFragDataUsed() const;
void markStaticUse(const TVariable &variable);
diff --git a/src/compiler/translator/ir/src/compile.rs b/src/compiler/translator/ir/src/compile.rs
index b23372c..3e6e593 100644
--- a/src/compiler/translator/ir/src/compile.rs
+++ b/src/compiler/translator/ir/src/compile.rs
@@ -569,6 +569,7 @@
// variables can be detected as inactive. However, reflection info for inactive variables must
// also be collected, so the transformation reports the set of active interface variables
// without removing inactive ones.
+ ir.meta.cache_built_in_static_use_before_dce();
let active_interface_variables = transform::run!(dead_code_eliminate, ir);
{
@@ -587,24 +588,31 @@
// varying, but the FS reading from it, which is not allowed. That's why inactive shader
// outputs are not removed. Inactive fragment shader outputs can be removed though.
//
- // For now, inactive built-ins are also retained.
- if options.remove_inactive_interface_variables {
- let retain_inactive_outputs = options.retain_inactive_fragment_outputs
- || ir.meta.get_shader_type() != ShaderType::Fragment;
+ let shader_type = ir.meta.get_shader_type();
+ let retain_inactive_outputs = !options.remove_inactive_interface_variables
+ || options.retain_inactive_fragment_outputs
+ || shader_type != ShaderType::Fragment;
- ir.meta.prune_global_variables(|variable_id, variable| {
- // Keep the variable if:
- //
- // * Not an interface variable, or
- // * Is active, or
- // * Is output and should be kept, or
- // * Is built-in
- !variable.is_interface_variable()
- || active_interface_variables.contains(&variable_id)
- || (retain_inactive_outputs && variable.decorations.has(Decoration::Output))
- || variable.is_built_in()
- });
- }
+ ir.meta.prune_global_variables(|variable_id, variable| {
+ // Keep the variable if:
+ //
+ // * Not an interface variable, or
+ // * Is active, or
+ // * Is output and should be kept, or
+ // * Is not built-in and options.remove_inactive_interface_variables is not set.
+ // * Inactive built-ins are always removed.
+ // * gl_Position is exceptionally retained so it can be zero-initialized.
+ // * gl_ClipDistance and gl_CullDistance are retained, as various AST transformations
+ // don't expect them to be pruned.
+ !variable.is_interface_variable()
+ || active_interface_variables.contains(&variable_id)
+ || (retain_inactive_outputs && variable.decorations.has(Decoration::Output))
+ || (!options.remove_inactive_interface_variables && !variable.is_built_in())
+ || matches!(
+ variable.built_in,
+ Some(BuiltIn::Position) | Some(BuiltIn::ClipDistance) | Some(BuiltIn::CullDistance)
+ )
+ });
}
fn common_post_variable_collection_transforms(ir: &mut IR, options: &Options) {
@@ -639,6 +647,7 @@
if options.clamp_indirect_indices {
let transform_options = transform::localized_workarounds::Options {
clamp_indirect_indices: options.clamp_indirect_indices,
+ max_dual_source_draw_buffers: options.limits.max_dual_source_draw_buffers,
};
transform::run!(localized_workarounds, ir, &transform_options);
}
diff --git a/src/compiler/translator/ir/src/ir.rs b/src/compiler/translator/ir/src/ir.rs
index 798635b..5e8e8fd 100644
--- a/src/compiler/translator/ir/src/ir.rs
+++ b/src/compiler/translator/ir/src/ir.rs
@@ -2500,6 +2500,7 @@
variables_pending_zero_initialization: HashSet<VariableId>,
// Shader reflection info
reflection_info: reflection::Info,
+ uses_secondary_frag_data: bool,
}
impl IRMeta {
@@ -2645,6 +2646,7 @@
per_vertex_out_is_redeclared: false,
variables_pending_zero_initialization: HashSet::new(),
reflection_info: reflection::Info::new(),
+ uses_secondary_frag_data: false,
}
}
@@ -3406,6 +3408,16 @@
type_info.get_element_type_id().unwrap()
}
+ // For some transformations, it matters if some built-in is statically used, even if it's
+ // dead-code eliminated. Calculate that before DCE.
+ pub fn cache_built_in_static_use_before_dce(&mut self) {
+ self.uses_secondary_frag_data =
+ self.get_built_in_variable(BuiltIn::SecondaryFragDataEXT).is_some();
+ }
+ pub fn uses_secondary_frag_data(&self) -> bool {
+ self.uses_secondary_frag_data
+ }
+
pub fn take_reflection_info(&mut self) -> reflection::Info {
std::mem::replace(&mut self.reflection_info, reflection::Info::new())
}
diff --git a/src/compiler/translator/ir/src/transform/initialize_uninitialized_variables.rs b/src/compiler/translator/ir/src/transform/initialize_uninitialized_variables.rs
index e015542..9f6ff57 100644
--- a/src/compiler/translator/ir/src/transform/initialize_uninitialized_variables.rs
+++ b/src/compiler/translator/ir/src/transform/initialize_uninitialized_variables.rs
@@ -289,12 +289,23 @@
let variable = TypedId::from_variable_id(ir_meta, id);
// For gl_FragData, the array elements are assigned one by one to keep the AST
// compatible with ESSL 1.00 which doesn't have array assignment.
- current_block = initialize_with_zeros(
- ir_meta,
- current_block,
- variable,
- options.loops_allowed_when_initializing_variables && !is_fragment_output_array,
- );
+ //
+ // If dual source blending is enabled, only initialize element 0.
+ current_block = if is_fragment_output_array && ir_meta.uses_secondary_frag_data() {
+ let element_zero = current_block.add_typed_instruction(instruction::index(
+ ir_meta,
+ variable,
+ TYPED_CONSTANT_ID_INT_ZERO,
+ ));
+ initialize_with_zeros(ir_meta, current_block, element_zero, false)
+ } else {
+ initialize_with_zeros(
+ ir_meta,
+ current_block,
+ variable,
+ options.loops_allowed_when_initializing_variables && !is_fragment_output_array,
+ )
+ };
ir_meta.on_variable_zero_initialization_done(id);
any_code_generated = true;
}
diff --git a/src/compiler/translator/ir/src/transform/localized_workarounds.rs b/src/compiler/translator/ir/src/transform/localized_workarounds.rs
index 5a360a3..54562f9 100644
Regression Test / PoC
diff --git a/src/tests/gl_tests/GLSLTest.cpp b/src/tests/gl_tests/GLSLTest.cpp
index cd8223f..52cdcf2 100644
--- a/src/tests/gl_tests/GLSLTest.cpp
+++ b/src/tests/gl_tests/GLSLTest.cpp
@@ -24215,6 +24215,7 @@
ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), kFS);
drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::red);
+ ASSERT_GL_NO_ERROR();
}
// Make sure gl_FragColor can be marked invariant in presence of GL_EXT_draw_buffers, even if
@@ -24233,6 +24234,112 @@
// Verify compilation only, as output is not written to.
ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), kFS);
}
+
+// 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
+// index a fragment output variable with a non-constant index in ESSL 300+.
+TEST_P(WebGLGLSLTest, FragDataIndexClampWithSecondaryFragData)
+{
+ 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
+precision mediump float;
+void main() {
+ // gl_SecondaryFragDataEXT follows, 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(1, 0, 0, 1);
+ for (int i = 0; i < 8; i++) {
+ gl_FragData[i] += vec4(-0.1, 0.05, 0.0, 0.0);
+ }
+
+ 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(51, 102, 0, 255), 1);
+ ASSERT_GL_NO_ERROR();
+}
+
+// Same test as FragDataIndexClampWithSecondaryFragData, but gl_SecondaryFragDataEXT is not written
+// to.
+TEST_P(WebGLGLSLTest, FragDataIndexClampWithSecondaryFragDataRead)
+{
+ 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
+precision mediump float;
+void main() {
+ gl_FragData[0] = vec4(1, 0, 0, 1);
+ for (int i = 0; i < 8; i++) {
+ gl_FragData[i] += vec4(-0.1, 0.05, 0.0, 0.0);
+ }
+
+ gl_FragData[0] += gl_SecondaryFragDataEXT[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(51, 102, 0, 255), 1);
+ ASSERT_GL_NO_ERROR();
+}
+
+// Same test as FragDataIndexClampWithSecondaryFragData, but gl_SecondaryFragDataEXT is only used in
+// dead code.
+TEST_P(WebGLGLSLTest, FragDataIndexClampWithSecondaryFragDataDeadCode)
+{
+ 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
+precision mediump float;
+void main() {
+ gl_FragData[0] = vec4(1, 0, 0, 1);
+ for (int i = 0; i < 8; i++) {
+ gl_FragData[i] += vec4(-0.1, 0.05, 0.0, 0.0);
+ }
+
+ if (false)
+ {
+ 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(51, 102, 0, 255), 1);
+ ASSERT_GL_NO_ERROR();
+}
+
+// Same test as FragDataIndexClampWithSecondaryFragData, but gl_SecondaryFragDataEXT is not actually
+// used (only the extension is enabled.
+TEST_P(WebGLGLSLTest, FragDataIndexClampWithSecondaryFragDataUnused)
+{
+ 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
+precision mediump float;
+void main() {
+ gl_FragData[0] = vec4(1, 0, 0, 1);
+ for (int i = 0; i < 8; i++) {
+ gl_FragData[i] += vec4(-0.1, 0.05, 0.0, 0.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(229, 13, 0, 255), 1);
+ ASSERT_GL_NO_ERROR();
+}
} // anonymous namespace
ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND_ES31_AND_ES32(
Original Bug Report
Potential OOB access to gl_FragData in ANGLE shaders via loop indexing
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: A logic flaw in ANGLE’s shader translator allows loop-based indirect indices to bypass array size tracking for gl_FragData when dual-source blending is active. This leads to incorrect clamping bounds being injected into the AST, resulting in potential out-of-bounds shader accesses in backend compilers like Metal or SPIR-V. On platforms with an unsandboxed GPU process (such as Android), this could potentially facilitate a sandbox escape.
Affected files:
third_party/angle/src/compiler/translator/ParseContext.cppthird_party/angle/src/compiler/translator/tree_ops/ClampIndirectIndices.cppthird_party/angle/src/compiler/translator/tree_ops/spirv/EmulateFragColorData.cppthird_party/angle/src/compiler/translator/msl/TranslatorMSL.cpp
Estimated timestamp from git blame: 2023-02-02
Background
In ANGLE, the gl_FragData built-in array typically has a size of MaxDrawBuffers (e.g., 8). However, when a shader uses the GL_EXT_blend_func_extended extension for dual-source blending (indicated by statically using gl_SecondaryFragDataEXT), the maximum number of draw buffers is reduced to MaxDualSourceDrawBuffers (typically 1).
Vulnerability Details
There is a potential vulnerability resulting from a mismatch between index validation, AST clamping, and backend code generation in ANGLE’s translator:
- Validation Bypass: In
third_party/angle/src/compiler/translator/ParseContext.cpp(TParseContext::addIndexExpression), the variablemMaxFragDataArrayIndexUsedtracks the highest index used to accessgl_FragData. However, this tracker is only updated when the index is a literal constant (indexConstantUnionis non-null). In ESSL 100, loop indices are validconstant-index-expressions, meaning they are successfully compiled but bypass this tracking. Consequently,TParseContext::validateShaderLayoutincorrectly evaluatesmMaxFragDataArrayIndexUsed >= mResources.MaxDualSourceDrawBuffersas false (since it remains 0). - Incorrect AST Clamping: The AST transformation pass
ClampIndirectIndicesinjects clamping bounds for indirect array accesses. It determines the clamp range by querying the array type’s size (ClampIndirectIndices.cpp:71). At this stage,gl_FragDatais still sized toMaxDrawBuffers(8). Therefore, the loop index is clamped to[0, 7]. - Backend Array Downsizing: Backend code generators (like
TranslatorMSL.cppfor Metal andEmulateFragColorData.cppfor SPIR-V) detect the dual-source blending state and shrink the actual outputgl_FragDataarray toMaxDualSourceDrawBuffers(1).
This sequence causes ANGLE to generate backend shader code (e.g., MSL) that declares an array of size 1, but accesses it using indices clamped to [0, 7] (e.g., gl_FragData[clamp(i, 0, 7)]).
Potential Impact
When the shader executes on the GPU, indices 1 through 7 will access memory outside the bounds of the generated output array. This provides an Out-of-Bounds (OOB) read/write primitive within the GPU’s execution context. Because the GPU process is unsandboxed on platforms like Android, corrupting driver structures via shader memory could potentially lead to a sandbox escape and arbitrary code execution.
Suggested Attacker Steps
(Note: Our tooling agent cannot run code, so these are theoretical steps to trigger the issue.)
- Create a WebGL context and enable the
WEBGL_draw_buffersandEXT_blend_func_extendedextensions. - Provide a malicious ESSL 100 fragment shader that enables
GL_EXT_draw_buffersandGL_EXT_blend_func_extended. - Statically use
gl_SecondaryFragDataEXTin the shader (e.g., write togl_SecondaryFragDataEXT[0]) to force ANGLE into dual-source blending mode. - Write a
forloop that iterates from 0 to 7. Inside the loop, use the loop variable to index and write togl_FragData(e.g.,gl_FragData[i] = vec4(1.0);). - Compile the shader and issue a draw call to execute the OOB writes on the GPU.
Suggested Fix
- Parser Fix: Update the logic in
TParseContext::addIndexExpressionto correctly track or reject loop indices forgl_FragDatawhenGL_EXT_blend_func_extendedis active. If the index is not a literal constant, the parser should either determine the maximum loop bound or defensively flag it as potentially exceedingMaxDualSourceDrawBuffers. - Clamping Fix: Update
ClampIndirectIndicesto evaluate the effective runtime size of built-in output arrays taking active extensions into account, rather than relying exclusively on the symbol’s initial type dimensions.
Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0
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.