High watch CVSS 9.6 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in ANGLE
DescriptionOut of bounds write in ANGLE
ComponentANGLE
Sub-componentTranslator
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker536505721
Fix commit3d3c181bdcf2 (angle/angle) +45/-0
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-07-19 (per vendor advisory)
Fix landed2026-07-22 (public git)
Disclosed2026-08-25
Patch gap34 days — fix public in source before the advisory

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_extended for dual-source blending; when it is used, valid gl_FragData indices are restricted to below GL_MAX_DUAL_SOURCE_DRAW_BUFFERS_EXT (typically 1), which is smaller than gl_MaxDrawBuffers.
`mMaxFragDataArrayIndexUsed`
the translator’s running maximum of the highest gl_FragData index 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.

Key insight
The translator tracked 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

  1. Craft a dual-source shader Author a fragment shader that enables GL_EXT_draw_buffers and GL_EXT_blend_func_extended and writes to gl_SecondaryFragDataEXT, engaging the restricted dual-source index limit for gl_FragData.
  2. Launder the array through a function Declare void f(out vec4 fragData[gl_MaxDrawBuffers]) and call f(gl_FragData), so the callee can index beyond GL_MAX_DUAL_SOURCE_DRAW_BUFFERS_EXT while mMaxFragDataArrayIndexUsed is never raised by the caller.
  3. 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.
  4. Trigger the out-of-bounds write The accepted shader drives gl_FragData writes at indices exceeding the dual-source draw-buffer allocation, producing an out-of-bounds write in ANGLE’s fragment output handling (inferred).

Impact Assessment

An attacker who can supply a WebGL/GLSL fragment shader (e.g. from a web page reaching the GPU process) could bypass the dual-source draw-buffer index validation and cause an out-of-bounds write in ANGLE. The advisory classifies this as an out-of-bounds write in ANGLE; no exploited-in-the-wild status is stated. Any escalation to memory corruption beyond the validation bypass demonstrated by the patch is (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.cpp
  • src/compiler/translator/ParseContext.h
  • src/tests/gl_tests/GLSLValidationTest.cpp

Audit Directions

  • Pass-by-argument bypass of index tracking
    Audit 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 propagation
    Look for validation keyed on qualifiers like EvqFragData that only inspects direct expressions and not function arguments or aliases through out/inout parameters.
  • Dual-source / extension-conditional limits
    Review 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.
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 typeId… accessChainLoad 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