CVE-2026-79189
Overview
Background
- `gl_FragData`
- a built-in fragment-shader output array sized to
gl_MaxDrawBuffers, used to write per-draw-buffer color values. - `gl_SecondaryFragDataEXT`
- the secondary color output introduced by
GL_EXT_blend_func_extendedfor dual-source blending; when it is used, validgl_FragDataindices are restricted to belowGL_MAX_DUAL_SOURCE_DRAW_BUFFERS_EXT(typically 1), which is smaller thangl_MaxDrawBuffers. - `mMaxFragDataArrayIndexUsed`
- the translator’s running maximum of the highest
gl_FragDataindex the shader touches, later validated against the dual-source limit. - `TParseContext`
- the ANGLE GLSL translator’s parse/validation context that performs semantic checks while building the intermediate representation.
Root Cause Analysis
When gl_SecondaryFragDataEXT is used, ANGLE must ensure that every gl_FragData access stays below GL_MAX_DUAL_SOURCE_DRAW_BUFFERS_EXT, and it tracks the largest index seen in mMaxFragDataArrayIndexUsed. Direct indexing of gl_FragData in main updated this maximum, but passing the whole gl_FragData array as an argument to a user-defined function did not: the callee could index the array (e.g. fragData[0], or any index up to gl_MaxDrawBuffers - 1) without the parse context ever raising mMaxFragDataArrayIndexUsed. The dual-source index check therefore passed even though the effective set of accessed indices exceeded the permitted dual-source range, violating the invariant that gl_FragData indices are bounded by the (smaller) dual-source draw-buffer count.
The fix adds functionCallFragDataCheck, invoked at every user-function call site, which detects an argument of qualifier EvqFragData and conservatively sets mMaxFragDataArrayIndexUsed to the array’s outermost size minus one, so the whole array is treated as accessed. This tightens validation: the subsequent dual-source bound check now fails and rejects the shader, closing the out-of-bounds write path.
gl_FragData index usage only for direct in-function indexing and ignored the case where the entire array is handed to a callee, so a shader could smuggle out-of-range accesses past the dual-source draw-buffer bound; the fix conservatively marks the maximum index as fully used whenever gl_FragData is passed to a function.Attack Path
- Craft a dual-source shader
Author a fragment shader that enables
GL_EXT_draw_buffersandGL_EXT_blend_func_extendedand writes togl_SecondaryFragDataEXT, engaging the restricted dual-source index limit forgl_FragData. - Launder the array through a function
Declare
void f(out vec4 fragData[gl_MaxDrawBuffers])and callf(gl_FragData), so the callee can index beyondGL_MAX_DUAL_SOURCE_DRAW_BUFFERS_EXTwhilemMaxFragDataArrayIndexUsedis never raised by the caller. - Bypass validation Because the pre-patch translator never accounted for the passed array, the dual-source bound check succeeds and the shader is accepted for translation.
- Trigger the out-of-bounds write
The accepted shader drives
gl_FragDatawrites at indices exceeding the dual-source draw-buffer allocation, producing an out-of-bounds write in ANGLE’s fragment output handling (inferred).
Impact Assessment
(inferred), and the precondition is that both GL_EXT_blend_func_extended and GL_EXT_draw_buffers are available and gl_SecondaryFragDataEXT is used.Files Changed
src/compiler/translator/ParseContext.cppsrc/compiler/translator/ParseContext.hsrc/tests/gl_tests/GLSLValidationTest.cpp
Audit Directions
- Pass-by-argument bypass of index trackingAudit every place that accumulates a maximum-index or usage counter on direct indexing (clip/cull distance, sample masks, other built-in arrays) but may miss the case where the whole array/aggregate is passed into a function.
- Built-in array qualifier propagationLook for validation keyed on qualifiers like
EvqFragDatathat only inspects direct expressions and not function arguments or aliases throughout/inoutparameters. - Dual-source / extension-conditional limitsReview checks whose bound tightens only when an optional feature is enabled (e.g.
gl_SecondaryFragDataEXT), where an alternate code path can reach the same output without re-triggering the tightened check.
Patch
From 3d3c181bdcf2aa1e7b0f05637247479e56ca075e Mon Sep 17 00:00:00 2001 From: Shahbaz Youssefi <[email protected]> Date: Tue, 21 Jul 2026 13:44:48 -0400 Subject: [PATCH] Translator: Check gl_FragData passed to function ... when gl_SecondaryFragDataEXT is used. Bug: chromium:536505721 Change-Id: I644862db03603892b4bdb493a7c2d0a4b694e46d Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8132423 Reviewed-by: Geoff Lang <[email protected]> Commit-Queue: Shahbaz Youssefi <[email protected]> Reviewed-by: Alexey Knyazev <[email protected]> --- diff --git a/src/compiler/translator/ParseContext.cpp b/src/compiler/translator/ParseContext.cpp index 7773434..74bc544 100644 --- a/src/compiler/translator/ParseContext.cpp +++ b/src/compiler/translator/ParseContext.cpp @@ -3338,6 +3338,22 @@ } } +void TParseContext::functionCallFragDataCheck(const TFunction *fnCandidate, + TIntermAggregate *fnCall) +{ + for (size_t i = 0; i < fnCandidate->getParamCount(); ++i) + { + TIntermTyped *argument = (*fnCall->getSequence())[i]->getAsTyped(); + if (argument->getType().getQualifier() == EvqFragData) + { + // The whole array is passed to the function. For validation purposes, assume all + // indices are accessed in the function. + ASSERT(argument->getType().isArray()); + mMaxFragDataArrayIndexUsed = argument->getType().getOutermostArraySize() - 1; + } + } +} + void TParseContext::checkInvariantVariableQualifier(bool invariant, const TQualifier qualifier, const TSourceLoc &invariantLocation) @@ -10062,6 +10078,7 @@ checkImageMemoryAccessForUserDefinedFunctions(fnCandidate, callNode); functionCallRValueLValueErrorCheck(fnCandidate, callNode); functionCallClipCullDistanceCheck(fnCandidate, callNode); + functionCallFragDataCheck(fnCandidate, callNode); mCallGraph[mCurrentFunction].insert(fnCandidate); mIRBuilder.callFunction(mFunctionToId.at(fnCandidate)); diff --git a/src/compiler/translator/ParseContext.h b/src/compiler/translator/ParseContext.h index 956bcf3..38d0604 100644 --- a/src/compiler/translator/ParseContext.h +++ b/src/compiler/translator/ParseContext.h @@ -220,6 +220,7 @@ TQualifier qualifier, const char *message); void functionCallClipCullDistanceCheck(const TFunction *fnCandidate, TIntermAggregate *fnCall); + void functionCallFragDataCheck(const TFunction *fnCandidate, TIntermAggregate *fnCall); void checkInvariantVariableQualifier(bool invariant, const TQualifier qualifier, const TSourceLoc &invariantLocation); diff --git a/src/tests/gl_tests/GLSLValidationTest.cpp b/src/tests/gl_tests/GLSLValidationTest.cpp index c7197e5..3785d58 100644 --- a/src/tests/gl_tests/GLSLValidationTest.cpp +++ b/src/tests/gl_tests/GLSLValidationTest.cpp @@ -5037,6 +5037,33 @@ "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) +{ + ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_blend_func_extended")); + ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_draw_buffers")); + + GLint maxDrawBuffers = 0, maxDualSourceDrawBuffers = 0; + glGetIntegerv(GL_MAX_DUAL_SOURCE_DRAW_BUFFERS_EXT, &maxDualSourceDrawBuffers); + glGetIntegerv(GL_MAX_DRAW_BUFFERS, &maxDrawBuffers); + ANGLE_SKIP_TEST_IF(maxDualSourceDrawBuffers == maxDrawBuffers); + + constexpr char kFS[] = R"(#extension GL_EXT_draw_buffers : require +#extension GL_EXT_blend_func_extended : require +precision mediump float; +void f(out vec4 fragData[gl_MaxDrawBuffers]) +{ + fragData[0] = vec4(0.1); +} +void main() { + f(gl_FragData); + gl_SecondaryFragDataEXT[0] = vec4(1.0); +})"; + 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 FragData at an index >= than gl_MaxDualSourceDrawBuffersEXT is fine if // SecondaryFragData is not used. Note that gl_MaxDualSourceDrawBuffersEXT is typically 1, while // the size of gl_FragData (gl_MaxDrawBuffers) is larger.
Regression Test / PoC
diff --git a/src/tests/gl_tests/GLSLValidationTest.cpp b/src/tests/gl_tests/GLSLValidationTest.cpp
index c7197e5..3785d58 100644
--- a/src/tests/gl_tests/GLSLValidationTest.cpp
+++ b/src/tests/gl_tests/GLSLValidationTest.cpp
@@ -5037,6 +5037,33 @@
"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)
+{
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_blend_func_extended"));
+ ANGLE_SKIP_TEST_IF(!IsGLExtensionEnabled("GL_EXT_draw_buffers"));
+
+ GLint maxDrawBuffers = 0, maxDualSourceDrawBuffers = 0;
+ glGetIntegerv(GL_MAX_DUAL_SOURCE_DRAW_BUFFERS_EXT, &maxDualSourceDrawBuffers);
+ glGetIntegerv(GL_MAX_DRAW_BUFFERS, &maxDrawBuffers);
+ ANGLE_SKIP_TEST_IF(maxDualSourceDrawBuffers == maxDrawBuffers);
+
+ constexpr char kFS[] = R"(#extension GL_EXT_draw_buffers : require
+#extension GL_EXT_blend_func_extended : require
+precision mediump float;
+void f(out vec4 fragData[gl_MaxDrawBuffers])
+{
+ fragData[0] = vec4(0.1);
+}
+void main() {
+ f(gl_FragData);
+ gl_SecondaryFragDataEXT[0] = vec4(1.0);
+})";
+ 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 FragData at an index >= than gl_MaxDualSourceDrawBuffersEXT is fine if
// SecondaryFragData is not used. Note that gl_MaxDualSourceDrawBuffersEXT is typically 1, while
// the size of gl_FragData (gl_MaxDrawBuffers) is larger.
Original Bug Report
OOB Write via Type-Mismatched OpStore in ANGLE SPIR-V
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: ANGLE’s EmulateFragColorData SPIR-V translator pass shrinks the gl_FragData array size but fails to update enclosing function parameter types. This causes OutputSPIRV to emit a type-mismatched OpStore that violates SPIR-V §3.42.8. In release builds, this invalid SPIR-V reaches the Vulkan driver unvalidated, potentially leading to a GPU memory out-of-bounds write.
Affected files:
third_party/angle/src/compiler/translator/tree_ops/spirv/EmulateFragColorData.cppthird_party/angle/src/compiler/translator/spirv/OutputSPIRV.cpp
Estimated timestamp from git blame: 2023-07-14
1. Summary of the Issue (Meant for Human Triage)
ANGLE contains a potential vulnerability in its SPIR-V shader translation pipeline on the Vulkan backend (the default for Android, Linux, and ChromeOS). When a WebGL1 shader uses dual-source blending (via the WEBGL_blend_func_extended extension), the EmulateFragColorData AST transformation pass correctly shrinks the built-in gl_FragData array from its maximum draw buffers size (typically 4-8) down to MaxDualSourceDrawBuffers (typically 1). However, this shrink operation is performed as a symbol-only replacement. If gl_FragData is passed as a whole array to a user-defined function, the enclosing function’s formal parameter type is not updated and remains sized at MaxDrawBuffers.
When OutputSPIRV generates SPIR-V for this function call, it allocates a temporary variable based on the (stale, larger) formal parameter type. After the function returns, it copies the temporary variable back to the (shrunk, smaller) gl_FragData output variable using an OpStore instruction. This results in a type-mismatched OpStore where a larger object is written into a smaller pointer, strictly violating SPIR-V specification §3.42.8.
Because SPIR-V validation (spirv::Validate()) is gated behind a debug-only ASSERT macro in ANGLE, release builds strip the validation entirely. The spec-invalid SPIR-V binary is subsequently fed directly into the Vulkan driver via vkCreateShaderModule. The behavior of Vulkan drivers processing a mismatched OpStore is undefined. Drivers that lower the composite OpStore element-wise based on the object type will write MaxDrawBuffers * 16 bytes into an output slot sized for 1 * 16 bytes, resulting in a potential out-of-bounds write in contiguous GPU memory. Since Output storage classes are not covered by Vulkan’s robustBufferAccess, this serves as a viable GPU-memory memory corruption primitive.
2. Proof-of-Concept & Detailed Execution Flow
Note: Our tooling does not execute code directly; the following represents a potential, theoretically verified execution trace derived from exhaustive static analysis.
Step-by-Step Execution Flow:
- Attacker Payload Injection: An attacker provides a WebGL1 fragment shader via
gl.shaderSource(). The shader enables#extension GL_EXT_draw_buffers : require(settinggl_MaxDrawBuffers> 1) and#extension GL_EXT_blend_func_extended : require(exposinggl_SecondaryFragDataEXTand restrictingMaxDualSourceDrawBuffersto 1). - Vulnerable Shader Structure: The shader declares:
void f(out vec4 d[gl_MaxDrawBuffers]) { d[0] = vec4(1.0); } void main() { f(gl_FragData); gl_SecondaryFragDataEXT[0] = vec4(0.0); } - Parsing Phase - L-Value Check Bypass:
TParseContext::checkCanBeLValue(ParseContext.cpp:1081) checks ifgl_FragDatais a valid l-value for theoutparameter. Theswitch(node->getQualifier())lacks anEvqFragDatacase, falling through todefaultand returningtrue(ParseContext.cpp:1118-1231). The whole-array usage is permitted. - Parsing Phase - Bounds Tracking Bypass: ANGLE tracks the highest index of
gl_FragDatainmMaxFragDataArrayIndexUsed(ParseContext.cpp:555). Because it only updates onEOpIndexDirect(ParseContext.cpp:7352), passinggl_FragDataas a whole array leavesmMaxFragDataArrayIndexUsedat0. The post-parse bounds check evaluates0 >= 1(MaxDualSourceDrawBuffers), which isfalse, bypassing validation (ParseContext.cpp:10500). - Translation Phase - Tree Transformation:
TranslatorSPIRV::translatedetectsgl_SecondaryFragDataEXTand triggers theEmulateFragColorDataAST transformation (TranslatorSPIRV.cpp:995). - Tree Operation - Incomplete Shrinking:
EmulateFragColorDataTraverser::visitSymbolshrinks the call-sitegl_FragDataarray size toMaxDualSourceDrawBuffers(size 1) and replaces it withwebgl_FragData(EmulateFragColorData.cpp:76-99). However, it drops the old argument node without updating the enclosingTFunctiondefinition. The function formal parameter remainsout vec4 d[MaxDrawBuffers]. - SPIR-V Emission - Temporary Variable Creation:
OutputSPIRVTraverser::createFunctionCall(OutputSPIRV.cpp:2260) processesf(webgl_FragData). It fetches the formal parameter typeparamType(vec4[MaxDrawBuffers]) and argument typeargType(vec4[1]). For theoutparameter, it allocates a function-local temporary variable matching the formal parameter type (%_arr_v4float_NwhereN=MaxDrawBuffers) (OutputSPIRV.cpp:2286-2289). - SPIR-V Emission - Copy-Out Initiation: After
OpFunctionCall, ANGLE copies the temporary variable back towebgl_FragData.nodeDataInitLValue(OutputSPIRV.cpp:778-793) bindspreSwizzleTypeIdto%_arr_v4float_N. - SPIR-V Emission - OpLoad Generation:
accessChainLoademits anOpLoadusing thepreSwizzleTypeId, yielding an object value%tempVarValueof sizeMaxDrawBuffers(OutputSPIRV.cpp:1100-1107). - SPIR-V Emission - Target Pointer Resolution:
accessChainStore(¶m, tempVarValue, ...)evaluates the AST reference, resolving it to the pointer ID%webgl_FragData(OutputSPIRV.cpp:1167->943-974). This pointer type is%_ptr_Output_arr_v4float_1(size 1 array in theOutputstorage class). - SPIR-V Emission - Spec Violation:
spirv::WriteStore(OutputSPIRV.cpp:1222) emitsOpStore %webgl_FragData %tempVarValue. This stores a value of sizeMaxDrawBuffersinto a pointer pointing to size1, violating SPIR-V Spec §3.42.8. - Release Execution - Validation Excision: In release builds,
ANGLE_ENABLE_ASSERTSis disabled. TheASSERT(spirv::Validate(*spirvBlobOut))macro (spv_utils.cpp:6201andlog_utils.h:263-271) evaluates to a no-op. The invalid SPIR-V is passed unvalidated tovkCreateShaderModule(vk_wrapper.h:1931), leading to potential driver-side GPU OOB memory corruption.
Suggested Fix:
Update TParseContext::checkCanBeLValue in ParseContext.cpp to properly reject whole-array l-value usage of gl_FragData (preventing EvqFragData from falling through to the permissive default case). Alternatively, ensure EmulateFragColorData updates/monomorphizes TFunction parameters when replacing the symbol argument.
3. Technical Verification Details (Automated Audit Logs)
Critic model verdict (2026-07-18 14:16:20):
- Severity: High (S1)
- Brief Notes / Reasoning: The vulnerability accurately describes a bug in ANGLE’s SPIR-V translator. In the
EmulateFragColorDatapass,gl_FragDatais shrunk tovec4[MaxDualSourceDrawBuffers](typically size 1) when dual-source blending is enabled. However, this is done via a symbol-only replacement, leaving enclosingTFunctionparameters (which remainvec4[MaxDrawBuffers]) unchanged. WhenOutputSPIRVTraverser::createFunctionCallperforms the copy-out for anoutparameter, it emits anOpStorewhere the source object has sizeMaxDrawBuffersand the target pointer has size 1, violating SPIR-V specification §3.42.8. Becauseangle::spirv::Validate()is gated behindASSERT()(angle_spirv_utils.cpp:68), the spec-invalid SPIR-V reachesvkCreateShaderModule(vk_wrapper.h:1931) unvalidated in release builds. The size mismatch on theOpStoreinto theOutputstorage class is bounds-bearing (acting as an implicit write size), allowing for driver-side GPU memory OOB writes. Per the severity guidelines, an ANGLE validating-layer gap emitting spec-invalid SPIR-V reaching the driver with a bounds-bearing parameter (or an OOB array operation in translator IR) is rated High (S1). It is not Critical (S0) because the corruption is strictly driver-side/GPU-memory, not in the GPU process host memory. The S2 Metal-only cap does not apply as this is the Vulkan backend.
Environmental Assumptions & Constraints:
- The target platform runs ANGLE with the Vulkan backend (default on Android, Linux, ChromeOS).
- The Vulkan device reports
dualSrcBlend == VK_TRUE. - WebGL contexts successfully expose both
WEBGL_draw_buffersandWEBGL_blend_func_extended. - The compilation environment uses release configuration where
ANGLE_ENABLE_ASSERTSis undefined, omittingspirv::Validate()checks. - The GPU driver lowers composite
OpStoreinstructions element-wise based on the right-hand object type rather than aborting entirely (behavior is technically undefined).
Codebase Investigator Verification Ledger:
- Query: “Check if OutputSPIRVTraverser emits OpLoad using the preSwizzleTypeId as the result type when accessChainLoad is called on a function-local temp variable.”
- Result: “Confirmed that spirv::WriteLoad is indeed called with accessChain.preSwizzleTypeId as the result type of the OpLoad instruction…
nodeDataInitLValuesetsdata->accessChain.preSwizzleTypeIdto the variable’stypeId…accessChainLoadcallsspirv::WriteLoadwithaccessChain.preSwizzleTypeId.”
- Result: “Confirmed that spirv::WriteLoad is indeed called with accessChain.preSwizzleTypeId as the result type of the OpLoad instruction…
- Query: “Verify if SPIR-V validation logic spv_utils.cpp calls spirv::Validate inside an ASSERT macro. Also verify that vk_wrapper.h calls vkCreateShaderModule without validating the blob in release builds.”
- Result: “In ‘spv_utils.cpp’ (line 6201), the SPIR-V validation logic calls ‘spirv::Validate’ inside the ‘ASSERT’ macro… Since the ‘ASSERT’ macro evaluates to a no-op in release builds (defined in ’log_utils.h’ when ‘ANGLE_ENABLE_ASSERTS’ is not set), any validation inside an ‘ASSERT’ is completely bypassed/compiled out in release builds…”
- Query: “Check if ValidateAST.cpp compares parameter types against argument types in visitFunctionCall.”
- Result: “No,
ValidateAST::visitFunctionCallinValidateAST.cpp(lines 454-478) does not compare parameter types against argument types… Furthermore, inShader.cpp,options.validateAST = true;is gated behind#if defined(ANGLE_ENABLE_ASSERTS).”
- Result: “No,
Evaluated with Chrome root at commit: b96d2ec58f4f5f92b540a723966b199d6e9951b4
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.