Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in ANGLE
DescriptionOut of bounds write in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker536505721
Fix commit3d3c181bdcf2 (angle/angle) +45/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • src/compiler/translator/ParseContext.cpp
  • src/compiler/translator/ParseContext.h
  • src/tests/gl_tests/GLSLValidationTest.cpp
From 3d3c181bdcf2aa1e7b0f05637247479e56ca075e Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
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 <geofflang@chromium.org>
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
Reviewed-by: Alexey Knyazev <lexa.knyazev@gmail.com>
---

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.
Loading diff…

Regression Test / PoC

shipped with the fix
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.
Loading diff…

Original Bug Report

reported by aw...@chromium.org

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.cpp
  • third_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:

  1. Attacker Payload Injection: An attacker provides a WebGL1 fragment shader via gl.shaderSource(). The shader enables #extension GL_EXT_draw_buffers : require (setting gl_MaxDrawBuffers > 1) and #extension GL_EXT_blend_func_extended : require (exposing gl_SecondaryFragDataEXT and restricting MaxDualSourceDrawBuffers to 1).
  2. 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); }
    
  3. Parsing Phase - L-Value Check Bypass: TParseContext::checkCanBeLValue (ParseContext.cpp:1081) checks if gl_FragData is a valid l-value for the out parameter. The switch(node->getQualifier()) lacks an EvqFragData case, falling through to default and returning true (ParseContext.cpp:1118-1231). The whole-array usage is permitted.
  4. Parsing Phase - Bounds Tracking Bypass: ANGLE tracks the highest index of gl_FragData in mMaxFragDataArrayIndexUsed (ParseContext.cpp:555). Because it only updates on EOpIndexDirect (ParseContext.cpp:7352), passing gl_FragData as a whole array leaves mMaxFragDataArrayIndexUsed at 0. The post-parse bounds check evaluates 0 >= 1 (MaxDualSourceDrawBuffers), which is false, bypassing validation (ParseContext.cpp:10500).
  5. Translation Phase - Tree Transformation: TranslatorSPIRV::translate detects gl_SecondaryFragDataEXT and triggers the EmulateFragColorData AST transformation (TranslatorSPIRV.cpp:995).
  6. Tree Operation - Incomplete Shrinking: EmulateFragColorDataTraverser::visitSymbol shrinks the call-site gl_FragData array size to MaxDualSourceDrawBuffers (size 1) and replaces it with webgl_FragData (EmulateFragColorData.cpp:76-99). However, it drops the old argument node without updating the enclosing TFunction definition. The function formal parameter remains out vec4 d[MaxDrawBuffers].
  7. SPIR-V Emission - Temporary Variable Creation: OutputSPIRVTraverser::createFunctionCall (OutputSPIRV.cpp:2260) processes f(webgl_FragData). It fetches the formal parameter type paramType (vec4[MaxDrawBuffers]) and argument type argType (vec4[1]). For the out parameter, it allocates a function-local temporary variable matching the formal parameter type (%_arr_v4float_N where N = MaxDrawBuffers) (OutputSPIRV.cpp:2286-2289).
  8. SPIR-V Emission - Copy-Out Initiation: After OpFunctionCall, ANGLE copies the temporary variable back to webgl_FragData. nodeDataInitLValue (OutputSPIRV.cpp:778-793) binds preSwizzleTypeId to %_arr_v4float_N.
  9. SPIR-V Emission - OpLoad Generation: accessChainLoad emits an OpLoad using the preSwizzleTypeId, yielding an object value %tempVarValue of size MaxDrawBuffers (OutputSPIRV.cpp:1100-1107).
  10. SPIR-V Emission - Target Pointer Resolution: accessChainStore(&param, 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 the Output storage class).
  11. SPIR-V Emission - Spec Violation: spirv::WriteStore (OutputSPIRV.cpp:1222) emits OpStore %webgl_FragData %tempVarValue. This stores a value of size MaxDrawBuffers into a pointer pointing to size 1, violating SPIR-V Spec §3.42.8.
  12. Release Execution - Validation Excision: In release builds, ANGLE_ENABLE_ASSERTS is disabled. The ASSERT(spirv::Validate(*spirvBlobOut)) macro (spv_utils.cpp:6201 and log_utils.h:263-271) evaluates to a no-op. The invalid SPIR-V is passed unvalidated to vkCreateShaderModule (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 EmulateFragColorData pass, gl_FragData is shrunk to vec4[MaxDualSourceDrawBuffers] (typically size 1) when dual-source blending is enabled. However, this is done via a symbol-only replacement, leaving enclosing TFunction parameters (which remain vec4[MaxDrawBuffers]) unchanged. When OutputSPIRVTraverser::createFunctionCall performs the copy-out for an out parameter, it emits an OpStore where the source object has size MaxDrawBuffers and the target pointer has size 1, violating SPIR-V specification §3.42.8. Because angle::spirv::Validate() is gated behind ASSERT() (angle_spirv_utils.cpp:68), the spec-invalid SPIR-V reaches vkCreateShaderModule (vk_wrapper.h:1931) unvalidated in release builds. The size mismatch on the OpStore into the Output storage 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_buffers and WEBGL_blend_func_extended.
  • The compilation environment uses release configuration where ANGLE_ENABLE_ASSERTS is undefined, omitting spirv::Validate() checks.
  • The GPU driver lowers composite OpStore instructions 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… nodeDataInitLValue sets data->accessChain.preSwizzleTypeId to the variable’s typeIdaccessChainLoad calls spirv::WriteLoad with accessChain.preSwizzleTypeId.”
  • 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::visitFunctionCall in ValidateAST.cpp (lines 454-478) does not compare parameter types against argument types… Furthermore, in Shader.cpp, options.validateAST = true; is gated behind #if defined(ANGLE_ENABLE_ASSERTS).”

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.

View on issue tracker