Medium chrome Uninitialized Memory 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in ANGLE
DescriptionMemory corruption in ANGLE
ComponentANGLE
Sub-componentInternals>GPU>ANGLE
Bug ClassUninitialized Memory
Tracker563352451
Fix commit4979a49c3fdb (angle/angle) +323/-94
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Background

Fragment outputs
a fragment shader may declare outputs narrower than vec4 (float, vec2, vec3); the unwritten components (notably alpha) are undefined.
Hardened contexts
security-sensitive ANGLE contexts where undefined values must not leak.
ExpandFragmentOutputsToVec4
the transform (new C++ tree-op + Rust IR pass) that pads non-vec4 outputs to gvec4 with zeros.

Root Cause Analysis

The commit “Always expand non-vec4 fragment outputs to vec4” makes ANGLE expand any fragment shader output narrower than 4 components (e.g. float/vec2/vec3 outputs) to a full gvec4, filling the missing components with 0, on hardened contexts for all backends. The commit message states the problem: “The alpha channel is undefined if unwritten, which leads to undefined values produced when blending.” Previously this was only a per-driver workaround (expand_fragment_outputs_to_vec4); the fix promotes it to a general transform (new ExpandFragmentOutputsToVec4 C++ tree-op and expand_fragment_outputs_to_vec4 Rust IR transform) run whenever expandFragmentOutputsToVec4 is set and the shader is ESSL 3.00+.

The root cause is therefore use of undefined/uninitialized fragment-output components: an unwritten alpha (or other missing component) carries an undefined value that is then consumed by blending and read back, which can expose uninitialized GPU register/memory contents. This CVE (bug chromium:563352451) is one of three sharing this fix; the advisory labels it “memory corruption,” but the diff shows an undefined/uninitialized-value defect, not a heap corruption primitive. Reclassified to UninitializedMemory. (The exact read-back of undefined data is the inferred consequence; the patch proves the undefined-component expansion fix.)

Key insight
Non-vec4 fragment outputs left missing components undefined, which blending then consumed; expanding all outputs to vec4 with zero fill removes the undefined read.

Attack Path

  1. ENTRY Supply a WebGL fragment shader (ESSL 3.00+) with a non-vec4 output on a hardened context.
  2. CONTROLLED INPUT Leave a component (e.g. alpha) unwritten so it holds an undefined value.
  3. MISSING CHECK Without the expansion, the undefined component is not zero-filled before use.
  4. IMPACT Blending/read-back consumes the undefined value, exposing uninitialized GPU contents. (Corruption, as the advisory labels it, is not shown; the demonstrated defect is undefined/uninitialized data.)

Impact Assessment

Undefined fragment-output components can expose uninitialized GPU memory/register values via blending on hardened contexts (GPU process, reachable from web content) — an uninitialized-data / info-leak defect rather than the advisory’s “memory corruption.” Severity medium per the advisory; no ITW stated.

Files Changed

  • src/compiler/translator/ir/src/transform.rs
  • src/libANGLE/renderer/gl/ShaderGL.cpp
  • src/libANGLE/renderer/gl/renderergl_utils.cpp
  • include/platform/autogen/FeaturesGL_autogen.h
  • util/autogen/angle_features_autogen.cpp
  • src/compiler.gni
  • src/tests/angle_end2end_tests_expectations.txt
  • src/compiler/translator/Compiler.cpp
  • src/compiler/translator/ir/src/transform/expand_fragment_outputs_to_vec4.rs
  • src/compiler/translator/glsl/TranslatorGLSL.cpp
  • src/compiler/translator/ir/src/ir.rs
  • src/tests/gl_tests/WebGLCompatibilityTest.cpp
  • src/compiler/translator/ir/src/transform/emulate_multi_draw.rs
  • src/compiler/translator/glsl/TranslatorESSL.cpp
  • src/compiler/translator/ir/BUILD.gn
  • src/libANGLE/Shader.cpp
  • util/autogen/angle_features_autogen.h
  • src/compiler/translator/tree_ops/glsl/ExpandFragmentOutputsToVec4.h
  • src/compiler/translator/tree_ops/ExpandFragmentOutputsToVec4.cpp
  • src/compiler/translator/ir/src/compile.rs
  • src/compiler/translator/ir/src/compile.cpp
  • src/tests/gl_tests/GLSLTest.cpp
  • src/compiler/translator/tree_ops/ExpandFragmentOutputsToVec4.h
  • include/platform/gl_features.json
  • src/compiler/translator/ir/src/transform/emulate_instanced_multiview.rs

Audit Directions

  • Undefined output components
    Audit shader-output handling for components left unwritten and later consumed (blending, read-back); ensure zero-fill/robust init on hardened contexts.
  • Per-driver workarounds promoted to general
    Review other Workarounds flags that guard undefined-value behavior for cases that should apply to all backends.
From 4979a49c3fdb872fcac93c6bfc02480ef14c5faf Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Sun, 20 Sep 2026 16:40:25 -0400
Subject: [PATCH] Always expand non-vec4 fragment outputs to vec4

... on hardened contexts.  The alpha channel is undefined if unwritten,
which leads to undefined values produced when blending.  Previously,
this was implemented as a driver bug workaround. which is now done for
all backends and drivers.

The MSL generator already does this during codegen regardless of
hardened contexts.  That's left alone for now and can be rethought
during the IR port.

Bug: chromium:563350676
Bug: chromium:563352451
Bug: chromium:563352515
Change-Id: Iafc64548b97811620d54bb195eb2b43a3212951e
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8427088
Reviewed-by: Yuxin Hu <yuxinhu@google.com>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
---
diff --git a/include/platform/gl_features.json b/include/platform/gl_features.json
index 080bf18..6557442 100644
--- a/include/platform/gl_features.json
+++ b/include/platform/gl_features.json
@@ -940,14 +940,6 @@
             "issue": "http://crbug.com/475877320"
         },
         {
-            "name": "expand_fragment_outputs_to_vec4",
-            "category": "Workarounds",
-            "description": [
-                "Expand fragment shader outputs to 4 components to work around compiler bugs."
-            ],
-            "issue": "http://crbug.com/491732188"
-        },
-        {
             "name": "recreate_mipmap_levels_before_generate",
             "category": "Workarounds",
             "description": [
diff --git a/src/compiler/translator/Compiler.cpp b/src/compiler/translator/Compiler.cpp
index d76cffe..8b15540 100644
--- a/src/compiler/translator/Compiler.cpp
+++ b/src/compiler/translator/Compiler.cpp
@@ -31,6 +31,7 @@
 #include "compiler/translator/tree_ops/DeferGlobalInitializers.h"
 #include "compiler/translator/tree_ops/EmulateGLFragColorBroadcast.h"
 #include "compiler/translator/tree_ops/EmulateMultiDrawShaderBuiltins.h"
+#include "compiler/translator/tree_ops/ExpandFragmentOutputsToVec4.h"
 #include "compiler/translator/tree_ops/FoldExpressions.h"
 #include "compiler/translator/tree_ops/InitializeVariables.h"
 #include "compiler/translator/tree_ops/PruneEmptyCases.h"
@@ -1172,6 +1173,14 @@
         }
     }
 
+    if (compileOptions.expandFragmentOutputsToVec4 && mShaderVersion >= 300)
+    {
+        if (!ExpandFragmentOutputsToVec4(this, root, &getSymbolTable()))
+        {
+            return false;
+        }
+    }
+
     return true;
 }
 
diff --git a/src/compiler/translator/glsl/TranslatorESSL.cpp b/src/compiler/translator/glsl/TranslatorESSL.cpp
index 69889c4..5c65939 100644
--- a/src/compiler/translator/glsl/TranslatorESSL.cpp
+++ b/src/compiler/translator/glsl/TranslatorESSL.cpp
@@ -17,7 +17,6 @@
 #include "compiler/translator/tree_ops/MonomorphizeUnsupportedFunctions.h"
 #include "compiler/translator/tree_ops/RecordConstantPrecision.h"
 #include "compiler/translator/tree_ops/RemoveDynamicIndexing.h"
-#include "compiler/translator/tree_ops/glsl/ExpandFragmentOutputsToVec4.h"
 #include "compiler/translator/tree_util/FindSymbolNode.h"
 #include "compiler/translator/tree_util/ReplaceClipCullDistanceVariable.h"
 #include "compiler/translator/tree_util/RunAtTheEndOfShader.h"
@@ -128,13 +127,6 @@
             return false;
         }
     }
-    if (compileOptions.expandFragmentOutputsToVec4)
-    {
-        if (!ExpandFragmentOutputsToVec4(this, root, &getSymbolTable()))
-        {
-            return false;
-        }
-    }
 
     // Write emulated built-in functions if needed.
     BuiltInFunctionEmulator builtInFunctionEmulator;
diff --git a/src/compiler/translator/glsl/TranslatorGLSL.cpp b/src/compiler/translator/glsl/TranslatorGLSL.cpp
index 6ee3f56..f63098a 100644
--- a/src/compiler/translator/glsl/TranslatorGLSL.cpp
+++ b/src/compiler/translator/glsl/TranslatorGLSL.cpp
@@ -18,7 +18,6 @@
 #include "compiler/translator/tree_ops/RemoveDynamicIndexing.h"
 #include "compiler/translator/tree_ops/RemoveInvariantDeclaration.h"
 #include "compiler/translator/tree_ops/RewriteTexelFetchOffset.h"
-#include "compiler/translator/tree_ops/glsl/ExpandFragmentOutputsToVec4.h"
 #include "compiler/translator/tree_ops/glsl/apple/RewriteRowMajorMatrices.h"
 
 namespace sh
@@ -138,13 +137,6 @@
             return false;
         }
     }
-    if (compileOptions.expandFragmentOutputsToVec4)
-    {
-        if (!ExpandFragmentOutputsToVec4(this, root, &getSymbolTable()))
-        {
-            return false;
-        }
-    }
 
     // Write emulated built-in functions if needed.
     BuiltInFunctionEmulator builtInFunctionEmulator;
diff --git a/src/compiler/translator/ir/src/compile.cpp b/src/compiler/translator/ir/src/compile.cpp
index dafb127..dfe05dc 100644
--- a/src/compiler/translator/ir/src/compile.cpp
+++ b/src/compiler/translator/ir/src/compile.cpp
@@ -155,6 +155,7 @@
     opt->retain_inactive_fragment_outputs            = options.retainInactiveFragmentOutputs;
     opt->scalarize_vec_and_mat_constructor_args      = options.scalarizeVecAndMatConstructorArgs;
     opt->clamp_indirect_indices                      = options.clampIndirectArrayBounds;
+    opt->expand_fragment_outputs_to_vec4             = options.expandFragmentOutputsToVec4;
 
     opt->rewrite_pixel_local_storage = compiler->hasPixelLocalStorageUniforms();
     opt->pls_options.implementation  = static_cast<ffi::PixelLocalStorageImpl>(options.pls.type);
diff --git a/src/compiler/translator/ir/src/compile.rs b/src/compiler/translator/ir/src/compile.rs
index 6dbfd5c..39411f0 100644
--- a/src/compiler/translator/ir/src/compile.rs
+++ b/src/compiler/translator/ir/src/compile.rs
@@ -219,6 +219,8 @@
         scalarize_vec_and_mat_constructor_args: bool,
         // Clamp non-constant indices to the bounds of the entity being indexed for robustness.
         clamp_indirect_indices: bool,
+        // Whether ESSL300 fragment outputs should be expanded to vec4s.
+        expand_fragment_outputs_to_vec4: bool,
 
         // Whether the ANGLE_pixel_local_storage extension has been used and there are PLS uniforms
         // to rewrite.
@@ -673,6 +675,11 @@
         };
         transform::run!(localized_workarounds, ir, &transform_options);
     }
+
+    if options.expand_fragment_outputs_to_vec4 && options.shader_version >= 300 {
+        debug_assert!(ir.meta.get_shader_type() == ShaderType::Fragment);
+        transform::run!(expand_fragment_outputs_to_vec4, ir);
+    }
 }
 
 fn initialize_global_pool_index_workaround() {
diff --git a/src/compiler/translator/ir/src/ir.rs b/src/compiler/translator/ir/src/ir.rs
index 68e95c4..48e2443 100644
--- a/src/compiler/translator/ir/src/ir.rs
+++ b/src/compiler/translator/ir/src/ir.rs
@@ -3354,17 +3354,18 @@
     // variable is replaced with the global variable and a new id is assigned to the interface
     // variable and returned.  This way, the shader does not need to be modified except for
     // possibly writing to the cache variable at the start of shader and reading from it at the
-    // end.
+    // end.  If requested, the original variable's type is overridden with a given type.
     pub fn declare_cached_global_for_variable(
         &mut self,
         variable_id: VariableId,
         cache_name: &'static str,
+        original_variable_type_override: Option<TypeId>,
     ) -> (VariableId, TypedId) {
         let variable = self.get_variable_mut(variable_id);
 
         // Replace the variable with a private global.
         let original_name = std::mem::replace(&mut variable.name, Name::new_temp(cache_name));
-        let type_id = variable.type_id;
+        let type_id = original_variable_type_override.unwrap_or(variable.type_id);
         let precision = variable.precision;
         let precise = variable.precise;
         let original_decorations =
diff --git a/src/compiler/translator/ir/src/transform.rs b/src/compiler/translator/ir/src/transform.rs
index cf13c07..4fb0668 100644
--- a/src/compiler/translator/ir/src/transform.rs
+++ b/src/compiler/translator/ir/src/transform.rs
@@ -9,6 +9,7 @@
 pub mod dealias;
 pub mod emulate_instanced_multiview;
 pub mod emulate_multi_draw;
+pub mod expand_fragment_outputs_to_vec4;
 pub mod initialize_uninitialized_variables;
 pub mod localized_workarounds;
 pub mod monomorphize_unsupported_functions;
diff --git a/src/compiler/translator/ir/src/transform/emulate_instanced_multiview.rs b/src/compiler/translator/ir/src/transform/emulate_instanced_multiview.rs
index 39ea3f1..7f8bbf0 100644
--- a/src/compiler/translator/ir/src/transform/emulate_instanced_multiview.rs
+++ b/src/compiler/translator/ir/src/transform/emulate_instanced_multiview.rs
@@ -84,7 +84,10 @@
 
     // Make a duplicate of gl_InstanceID to add to globals, and let the shader use the replacement.
     (
-        state.ir_meta.declare_cached_global_for_variable(replaced_instance_id, "InstanceID").1,
+        state
+            .ir_meta
+            .declare_cached_global_for_variable(replaced_instance_id, "InstanceID", None)
+            .1,
         replaced_instance_id_typed,
     )
 }
diff --git a/src/compiler/translator/ir/src/transform/emulate_multi_draw.rs b/src/compiler/translator/ir/src/transform/emulate_multi_draw.rs
index a942cef..d3d37ba 100644
--- a/src/compiler/translator/ir/src/transform/emulate_multi_draw.rs
+++ b/src/compiler/translator/ir/src/transform/emulate_multi_draw.rs
@@ -64,7 +64,7 @@
 fn add_base_vertex_to_vertex_id(ir: &mut IR) {
     if let Some(vertex_id) = ir.meta.get_built_in_variable(BuiltIn::VertexID) {
         let replaced_vertex_id = TypedId::from_variable_id(&ir.meta, vertex_id);
-        let vertex_id = ir.meta.declare_cached_global_for_variable(vertex_id, "VertexID").1;
+        let vertex_id = ir.meta.declare_cached_global_for_variable(vertex_id, "VertexID", None).1;
 
         let base_vertex = ir.meta.get_or_declare_built_in_variable(BuiltIn::BaseVertex).1;
 
diff --git a/src/compiler/translator/ir/src/transform/expand_fragment_outputs_to_vec4.rs b/src/compiler/translator/ir/src/transform/expand_fragment_outputs_to_vec4.rs
new file mode 100644
index 0000000..294f53e
--- /dev/null
+++ b/src/compiler/translator/ir/src/transform/expand_fragment_outputs_to_vec4.rs
@@ -0,0 +1,153 @@
+// Copyright 2026 The ANGLE Project Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// Turn gvecN (N < 4) fragment outputs to gvec4, filling the missing components with 0.
+
+use crate::ir::*;
+use crate::*;
+
+pub fn run(ir: &mut IR) {
+    for variable_id in ir.meta.all_global_variables().clone() {
+        // Must be an `out` variable
+        let variable = ir.meta.get_variable(variable_id);
+        if !variable.decorations.has(Decoration::Output) {
+            continue;
+        }
+
+        // Must have fewer than 4 components.
+        let base_type_id = ir.meta.get_pointee_type(variable.type_id);
+        let (base_type_id, array_size) =
+            if let &Type::Array(base_type_id, size) = ir.meta.get_type(base_type_id) {
+                (base_type_id, Some(size))
+            } else {
+                (base_type_id, None)
+            };
+
+        if let Some((expanded_type_id, component_count, zero)) = expand_type_to_vec4(base_type_id) {
+            replace_output_variable(
+                ir,
+                variable_id,
+                variable.name.name,
+                expanded_type_id,
+                component_count,
+                array_size,
+                zero,
+            );
+        }
+    }
+}
+
+fn expand_type_to_vec4(type_id: TypeId) -> Option<(TypeId, u32, TypedId)> {
+    match type_id {
+        TYPE_ID_FLOAT => Some((TYPE_ID_VEC4, 1, TYPED_CONSTANT_ID_FLOAT_ZERO)),
+        TYPE_ID_VEC2 => Some((TYPE_ID_VEC4, 2, TYPED_CONSTANT_ID_FLOAT_ZERO)),
+        TYPE_ID_VEC3 => Some((TYPE_ID_VEC4, 3, TYPED_CONSTANT_ID_FLOAT_ZERO)),
+        TYPE_ID_INT => Some((TYPE_ID_IVEC4, 1, TYPED_CONSTANT_ID_INT_ZERO)),
+        TYPE_ID_IVEC2 => Some((TYPE_ID_IVEC4, 2, TYPED_CONSTANT_ID_INT_ZERO)),
+        TYPE_ID_IVEC3 => Some((TYPE_ID_IVEC4, 3, TYPED_CONSTANT_ID_INT_ZERO)),
+        TYPE_ID_UINT => Some((TYPE_ID_UVEC4, 1, TYPED_CONSTANT_ID_UINT_ZERO)),
+        TYPE_ID_UVEC2 => Some((TYPE_ID_UVEC4, 2, TYPED_CONSTANT_ID_UINT_ZERO)),
+        TYPE_ID_UVEC3 => Some((TYPE_ID_UVEC4, 3, TYPED_CONSTANT_ID_UINT_ZERO)),
+        _ => None,
+    }
+}
+
+fn replace_output_variable(
+    ir: &mut IR,
+    variable_id: VariableId,
+    name: &'static str,
+    expanded_type_id: TypeId,
+    component_count: u32,
+    array_size: Option<u32>,
+    basic_type_zero: TypedId,
+) {
+    let complete_type = if let Some(array_size) = array_size {
+        ir.meta.get_array_type_id(expanded_type_id, array_size)
+    } else {
+        expanded_type_id
+    };
+
+    // Use a global instead of the fragment output, declare a gvec4 fragment output instead and
+    // and at the end of the shader copy it over to the expanded variable.
+    let new_output =
+        ir.meta.declare_cached_global_for_variable(variable_id, name, Some(complete_type)).1;
+    let global = TypedId::from_variable_id(&ir.meta, variable_id);
+
+    let mut postamble = Block::new();
+
+    if let Some(array_size) = array_size {
+        for index in 0..array_size {
+            // globalN = AccessArrayElement global N
+            // outputN = AccessArrayElement output N
+            let index = ir.meta.get_constant_uint_typed(index, Precision::Low);
+            let global_indexed =
+                postamble.add_typed_instruction(instruction::index(&mut ir.meta, global, index));
+            let output_indexed = postamble.add_typed_instruction(instruction::index(
+                &mut ir.meta,
+                new_output,
+                index,
+            ));
+            copy_and_expand(
+                &mut ir.meta,
+                &mut postamble,
+                global_indexed,
+                output_indexed,
+                expanded_type_id,
+                component_count,
+                basic_type_zero,
+            );
+        }
+    } else {
+        copy_and_expand(
+            &mut ir.meta,
+            &mut postamble,
+            global,
+            new_output,
+            expanded_type_id,
+            component_count,
+            basic_type_zero,
+        );
+    }
+
+    ir.append_to_main(postamble);
+}
+
+fn copy_and_expand(
+    ir_meta: &mut IRMeta,
+    postamble: &mut Block,
+    global: TypedId,
+    output: TypedId,
+    expanded_type_id: TypeId,
+    component_count: u32,
+    basic_type_zero: TypedId,
+) {
+    // Produce statements like output = vec4(global.x, global.y, zero, zero).
+    let mut constructor_args = if component_count > 1 {
+        // x        = AccessVectorComponent global 0
+        // x'       = Load x
+        // y        = AccessVectorComponent global 1
+        // y'       = Load y
+        // expanded = ConstructVectorFromMultiple (x, y, zero, zero)
+        (0..component_count)
+            .map(|component| {
+                let swizzle = postamble.add_typed_instruction(instruction::vector_component(
+                    ir_meta, global, component,
+                ));
+                postamble.add_typed_instruction(instruction::load(ir_meta, swizzle))
+            })
+            .collect::<Vec<_>>()
+    } else {
+        // x        = Load global
+        // expanded = ConstructVectorFromMultiple (x, zero, zero, zero)
+        vec![postamble.add_typed_instruction(instruction::load(ir_meta, global))]
+    };
+    constructor_args.resize(4, basic_type_zero);
+    let expanded = postamble.add_typed_instruction(instruction::construct(
+        ir_meta,
+        expanded_type_id,
+        constructor_args,
+        None,
+    ));
+    postamble.add_void_instruction(OpCode::Store(output, expanded));
+}
diff --git a/src/compiler/translator/tree_ops/ExpandFragmentOutputsToVec4.cpp b/src/compiler/translator/tree_ops/ExpandFragmentOutputsToVec4.cpp
new file mode 100644
index 0000000..2875551
--- /dev/null
+++ b/src/compiler/translator/tree_ops/ExpandFragmentOutputsToVec4.cpp
@@ -0,0 +1,151 @@
+// Copyright 2026 The ANGLE Project Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "compiler/translator/tree_ops/ExpandFragmentOutputsToVec4.h"
+
+#include "compiler/translator/tree_util/IntermNode_util.h"
+#include "compiler/translator/tree_util/ReplaceVariable.h"
+#include "compiler/translator/tree_util/RunAtTheEndOfShader.h"
+
+namespace sh
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 2382235e..76a3a9f 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -219,6 +219,9 @@
 535213600 WIN NVIDIA OPENGL : PbufferTestES3.BindTexImageAndCopyTextureInvertedSrc/* = SKIP
 535213600 WIN NVIDIA GLES : PbufferTestES3.BindTexImageAndCopyTextureInvertedSrc/* = SKIP
 535213600 WIN NVIDIA GLES : PbufferTestES3.BindTexImageAndCopyTexSubTextureInvertedDst/* = SKIP
+564530612 WIN INTEL D3D11 : HardenedContextBlendTest.AlphaBlendNoAlphaChannelInSrcAndDst/* = SKIP
+564530612 WIN INTEL D3D11 : HardenedContextBlendTest.AlphaBlendNoAlphaChannelInSrcAndDst2/* = SKIP
+564530612 WIN INTEL D3D11 : HardenedContextBlendTest.AlphaBlendNoAlphaChannelInSrcAndDstArray/* = SKIP
 
 42266241 PIXEL6 VULKAN : VertexAttributeTestES31.MismatchingSignsChangingAttributeType/* = SKIP
 42266241 PIXEL6 VULKAN : VertexAttributeTestES31.MismatchingSignsChangingProgramType/* = SKIP
diff --git a/src/tests/gl_tests/GLSLTest.cpp b/src/tests/gl_tests/GLSLTest.cpp
index 74b7fa4..8ccb1e5 100644
--- a/src/tests/gl_tests/GLSLTest.cpp
+++ b/src/tests/gl_tests/GLSLTest.cpp
@@ -24362,16 +24362,13 @@
     ASSERT_GL_NO_ERROR();
 }
 
-class GLSLTest_ES3_Blend : public GLSLTest_ES3
-{};
-
 // Test alpha blend where both the framebuffer and shader miss the alpha channel.  The spec says
 // that:
 //
 // > If a color buffer has no A value, then A_d is taken to be 1.
 //
 // But it says nothing about what happens if the shader does not write to alpha and A_s.
-TEST_P(GLSLTest_ES3_Blend, AlphaBlendNoAlphaChannelInSrcAndDst)
+TEST_P(GLSLTest_ES3, AlphaBlendNoAlphaChannelInSrcAndDst)
 {
     GLTexture color;
     glBindTexture(GL_TEXTURE_2D, color);
@@ -24407,7 +24404,7 @@
 }
 
 // Test alpha blend where the framebuffer misses the alpha channel, but the shader writes to alpha.
-TEST_P(GLSLTest_ES3_Blend, AlphaBlendNoAlphaChannelInDst)
+TEST_P(GLSLTest_ES3, AlphaBlendNoAlphaChannelInDst)
 {
     GLTexture color;
     glBindTexture(GL_TEXTURE_2D, color);
@@ -24438,7 +24435,7 @@
 
 // Test blend where the framebuffer misses the alpha channel.  Uses (GL_DST_COLOR, GL_ZERO) blend
 // that hits an optimization path in the mesa/Radeon driver.
-TEST_P(GLSLTest_ES3_Blend, ColorBlendNoAlphaChannelInDst)
+TEST_P(GLSLTest_ES3, ColorBlendNoAlphaChannelInDst)
 {
     GLTexture color;
     glBindTexture(GL_TEXTURE_2D, color);
@@ -26101,10 +26098,5 @@
                        ES3_METAL(),
                        ES3_VULKAN());
 
-GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(GLSLTest_ES3_Blend);
-ANGLE_INSTANTIATE_TEST_ES3_AND(GLSLTest_ES3_Blend,
-                               ES3_OPENGL().enable(Feature::ExpandFragmentOutputsToVec4),
-                               ES3_OPENGLES().enable(Feature::ExpandFragmentOutputsToVec4));
-
 ANGLE_INSTANTIATE_TEST_ES2_AND(GLSLTestPassthrough,
                                ES2_OPENGLES().enable(Feature::ForcePassthroughShaders));
diff --git a/src/tests/gl_tests/WebGLCompatibilityTest.cpp b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
index f00c1b5..0c21744 100644
--- a/src/tests/gl_tests/WebGLCompatibilityTest.cpp
+++ b/src/tests/gl_tests/WebGLCompatibilityTest.cpp
@@ -368,6 +368,9 @@
 class HardenedContextTestES31 : public HardenedContextTest
 {};
 
+class HardenedContextBlendTest : public HardenedContextTest
+{};
+
 // Context creation would fail if EGL_ANGLE_create_context_webgl_compatibility was not available so
 // the GL extension should always be present
 TEST_P(WebGLCompatibilityTest, ExtensionStringExposed)
@@ -9058,6 +9061,107 @@
     }
 }
 
+// Test alpha blend where both the framebuffer and shader miss the alpha channel.  The spec says
+// that:
+//
+// > If a color buffer has no A value, then A_d is taken to be 1.
+//
+// But it says nothing about what happens if the shader does not write to alpha and A_s.
+TEST_P(HardenedContextBlendTest, AlphaBlendNoAlphaChannelInSrcAndDst)
+{
+    GLTexture color;
+    glBindTexture(GL_TEXTURE_2D, color);
+    glTexStorage2D(GL_TEXTURE_2D, 1, GL_R8, 1, 1);
+    constexpr uint8_t kInitialValue = 0x10;
+    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 1, 1, GL_RED, GL_UNSIGNED_BYTE, &kInitialValue);
+
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color, 0);
+    ANGLE_SKIP_TEST_IF(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE);
+
+    constexpr char kFS[] = R"(#version 300 es
+out mediump float color;
+void main() {
+    color = 0.2;
+})";
+
+    ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Simple(), kFS);
+    // A_d is implicitly one.  But A_s is undefined.
+    glEnable(GL_BLEND);
+    glBlendFunc(GL_SRC_ALPHA, GL_DST_ALPHA);
+    glBlendEquation(GL_FUNC_ADD);
+    drawQuad(program, essl3_shaders::PositionAttrib(), 0.0f);
+    // Normally, the result cannot be known given A_s is undefined, but in hardened contexts it's
+    // set to 0.
+    EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(0x10, 0, 0, 255), 1);
+    ASSERT_GL_NO_ERROR();
+}
+
+// Similar test as AlphaBlendNoAlphaChannelInSrcAndDst, but with a vec2
+TEST_P(HardenedContextBlendTest, AlphaBlendNoAlphaChannelInSrcAndDst2)
+{
+    GLTexture color;
+    glBindTexture(GL_TEXTURE_2D, color);
+    glTexStorage2D(GL_TEXTURE_2D, 1, GL_RG8, 1, 1);
+    constexpr std::array<uint8_t, 2> kInitialValue = {0x10, 0x30};
+    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 1, 1, GL_RG, GL_UNSIGNED_BYTE, kInitialValue.data());
+
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color, 0);
+    ANGLE_SKIP_TEST_IF(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE);
+
+    constexpr char kFS[] = R"(#version 300 es
+out mediump vec2 color;
+void main() {
+    color = vec2(0.2, 0.4);
+})";
+
+    ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Simple(), kFS);
+    // A_d is implicitly one.  But A_s is undefined.
+    glEnable(GL_BLEND);
+    glBlendFunc(GL_SRC_ALPHA, GL_DST_ALPHA);
+    glBlendEquation(GL_FUNC_ADD);
+    drawQuad(program, essl3_shaders::PositionAttrib(), 0.0f);
+    // Normally, the result cannot be known given A_s is undefined, but in hardened contexts it's
+    // set to 0.
+    EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(0x10, 0x30, 0, 255), 1);
+    ASSERT_GL_NO_ERROR();
+}
+
+// Similar test as AlphaBlendNoAlphaChannelInSrcAndDst, but with an array
+TEST_P(HardenedContextBlendTest, AlphaBlendNoAlphaChannelInSrcAndDstArray)
+{
+    GLTexture color;
+    glBindTexture(GL_TEXTURE_2D, color);
+    glTexStorage2D(GL_TEXTURE_2D, 1, GL_R8, 1, 1);
+    constexpr uint8_t kInitialValue = 0x10;
+    glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 1, 1, GL_RED, GL_UNSIGNED_BYTE, &kInitialValue);
+
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color, 0);
+    ANGLE_SKIP_TEST_IF(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE);
+
+    constexpr char kFS[] = R"(#version 300 es
+out mediump float color[1];
+void main() {
+    color[0] = 0.2;
+})";
+
+    ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Simple(), kFS);
+    // A_d is implicitly one.  But A_s is undefined.
+    glEnable(GL_BLEND);
+    glBlendFunc(GL_SRC_ALPHA, GL_DST_ALPHA);
+    glBlendEquation(GL_FUNC_ADD);
+    drawQuad(program, essl3_shaders::PositionAttrib(), 0.0f);
+    // Normally, the result cannot be known given A_s is undefined, but in hardened contexts it's
+    // set to 0.
+    EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(0x10, 0, 0, 255), 1);
+    ASSERT_GL_NO_ERROR();
+}
+
 ANGLE_INSTANTIATE_TEST_ES2_AND_ES3(WebGLCompatibilityTest);
 
 ANGLE_INSTANTIATE_TEST_ES2(WebGL1CompatibilityTest);
@@ -9068,6 +9172,9 @@
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(HardenedContextTest);
 ANGLE_INSTANTIATE_TEST_ES3(HardenedContextTest);
 
+GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(HardenedContextBlendTest);
+ANGLE_INSTANTIATE_TEST_ES3(HardenedContextBlendTest);
+
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(HardenedContextTestRestrictBaseLevel);
 ANGLE_INSTANTIATE_TEST(
     HardenedContextTestRestrictBaseLevel,
Loading diff…

Original Bug Report

reported by vm...@google.com

GPU Register Residue Leak via Sub-vec4 Fragment Outputs in ANGLE Vulkan Backend

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 Vulkan backend compiles WebGL2 fragment shaders with sub-vec4 color outputs (e.g. layout(location=0) out vec2 c;, legal ESSL 3.00) into SPIR-V that declares the output at its literal size, so the attachment’s remaining component words “do not correspond to any fragment shader output” and per Vulkan spec 15.3 receive driver-undefined values on every draw. Web content reads those bytes back with gl.readPixels(). The Metal and GL backends both carry translator-level zero-fill fixes for this exact leak class (“indeterminate GPU register residue leaking into color attachments”); the SPIR-V/Vulkan pipeline has no equivalent, and no other layer (WebGL output zero-init, write masks, draw-time validation) can reach the missing component words.

Affected files:

Estimated timestamp from git blame: 2021-05-19

Summary

WebGL2 accepts fragment shaders whose color output has fewer than 4 components; drawing one into a 4-component attachment (RGBA8) is legal-but-undefined at the GLES level, so ANGLE must neutralize the undefinedness rather than reject it. On Metal and GL, ANGLE’s translators widen such outputs to vec4 and zero the added channels for exactly this reason. The SPIR-V translator does not: the Location-0 interface variable stays v2float, the pipeline colorWriteMask stays full RGBA, and Vulkan 15.3’s second undefined clause applies to the attachment’s B/A component words on every covered pixel.

This was demonstrated end-to-end in stock Chrome on the ANGLE Vulkan backend (two Vulkan ICDs): with the FBO pre-cleared to a sentinel, a vec2-output draw destroyed the sentinel’s B/A bytes in all 32768/32768 pixels — the undefined words are written to the attachment and returned by readPixels, with no masking or validation layer intervening. SPIR-V emitted by ANGLE’s own translator for the same shader confirms the v2float interface with a single vec2 store. On the software rasterizers available for testing, the undefined words materialize as constant 0; whether they carry secret-bearing residue on hardware GPUs is driver/architecture-dependent — ANGLE’s own Metal fix comment, the AMD+Mesa GL workaround, and the 2026 “Uninitialized Use in ANGLE” CVE batches (e.g. CVE-2026-11109/-11110/-11123/-11138, all cross-origin-leak descriptions, Chromium severity Medium) establish that real hardware exhibits residue for this class.

Configurations affected:

  • Android Chrome with ANGLE-on-Vulkan — DefaultANGLEVulkan is an active fieldtrial rollout candidate (testing/variations/fieldtrial_testing_config.json:6426-6440); no flags or user interaction for that population
  • Desktop Chrome with --use-angle=vulkan (manual opt-in)
  • WebGL1 alone cannot trigger it (ESSL1 outputs are vec4 gl_FragColor/gl_FragData)

Root Cause Analysis

  1. Frontend accepts the size mismatch by design: third_party/angle/src/libANGLE/validationES.cpp:445-471 — draw-time validation checks only that an output variable exists per written draw buffer (ValidateFragmentShaderColorBufferMaskMatch) and that float/int/uint component types match (ValidateFragmentShaderColorBufferTypeMatch). Component counts are never compared — and cannot be rejected, since GLES 3.2 §15.2 makes the mismatch legal-but-undefined and WebGL2’s “shader-output-incompatible” definition excludes count mismatches.
  2. SPIR-V translator emits the output at declared size: third_party/angle/src/compiler/translator/spirv/TranslatorSPIRV.cpp:939-960 — the fragment-output walk special-cases only three built-in outputs; user color outputs flow through untouched:
    // TranslatorSPIRV.cpp:939-960 (abridged)
    for (const ShaderVariable &outputVar : mOutputVariables)
    {
        if (outputVar.name == "gl_SampleMask") { ... continue; }
        if (outputVar.name == "gl_SecondaryFragDataEXT") { ... continue; }
        if (outputVar.yuv) { ... continue; }   // <-- no size normalization for user outputs
    }
    
    BuildSPIRV.cpp:1895-1938 then declares OpVariable %_ptr_Output_v2float, and BuildSPIRV.cpp:2168-2206 adds only a Location decoration. Pipeline-time SPIR-V transforms (renderer/vulkan/spv_utils.cpp:415-448) preserve output sizes.
  3. WebGL output zero-init cannot reach the missing words: renderer/vulkan/ShaderVk.cpp:38-41 enables initOutputVariables for WebGL, which inserts c = vec2(0); (Compiler.cpp:1674-1734). That neutralizes Vulkan 15.3’s first undefined clause (declared-but-unwritten components) but is structurally unable to affect component words that have no corresponding output at all — the second clause.
  4. Write masking is per-attachment, not per-component: ContextVk.cpp:4731-4745 computes missingOutputsMask = ~shaderOutMask & framebufferMask over gl::DrawBufferMask (one bit per draw buffer). An active vec2 output keeps its draw buffer’s bit clear, so vk_cache_utils.cpp:4249-4258 leaves colorWriteMask at full RGBA; masks are never derived from output component counts.
  5. Vulkan writes driver-undefined values into the uncovered words: Vulkan spec 15.3 “Fragment Output Interface”: “Component words which do not correspond to any fragment shader output will also result in undefined values for blending or color attachment writes.” The B/A attachment bytes — and, with SRC_ALPHA-based blending enabled, the blend factors modulating the written R/G channels — become driver-chosen values that gl.readPixels() returns to JavaScript. This violates WebGL’s Resource Restrictions guarantee that shaders cannot access uninitialized data.

Reproduction

  1. Open a page in Chrome with the ANGLE Vulkan backend (Android DefaultANGLEVulkan population, or desktop --use-angle=vulkan) and create a WebGL2 context.
  2. Compile/link: trivial full-screen-triangle VS; FS = #version 300 es\nprecision mediump float;\nlayout(location=0) out vec2 c;\nvoid main(){ c = vec2(0.25, 0.5); }.
  3. Create an RGBA8 FBO and clear it to a sentinel color (e.g. RGBA 51,102,153,204) to distinguish “preserved” from “overwritten”.
  4. gl.drawArrays(gl.TRIANGLES, 0, 3) — all validation passes; pipeline write mask is RGBA.
  5. gl.readPixels(...) and inspect the B/A byte of each pixel. Optional blend variant: gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) makes the undefined alpha word modulate the written R/G channels.
  6. Expected: a correct implementation leaves the missing component words defined (zero-filled, as on the Metal/GL backends after their fixes).

Observed: on stock Chrome with the ANGLE Vulkan backend (verified on two Vulkan ICDs — Mesa lavapipe and SwiftShader), every one of 32768 pixels returns the shader’s R/G values while the B/A sentinel is destroyed: the two component words with no corresponding shader output are written to the attachment and read back, confirming the full-RGBA write mask and the absence of any intervening layer. The translator-emitted SPIR-V shows the v2float Location-0 interface with a single vec2 store. On these CPU rasterizers the undefined words materialize as constant 0 (benign); on hardware GPUs, per ANGLE’s own Metal fix rationale and the 2026 ANGLE uninitialized-use CVE batches, the same interface gap is expected to surface indeterminate GPU register residue — that content-bearing half is hardware-dependent and was not directly observable in this environment.

Severity

medium — read-only information leak of indeterminate GPU pipeline values to unprivileged web content; no memory corruption or write primitive. Reachable without flags for the Android ANGLE-Vulkan fieldtrial population, so no off-by-default demotion applies. Rated medium rather than high (GPU-process info-leak row) because the leaked substance is transient GPU hardware register/export residue rather than demonstrated GPU-process memory, and cross-origin carriage — while plausible given the shared GPU process and consistent with how Chromium described the same class in CVE-2026-11109/-11110/-11123/-11138 (all triaged Medium) — was not demonstrated here; if hardware testing shows other-origin data in the residue, escalation to high would be justified.

Suggested Fix Direction

Mirror the Metal/GL pattern in the SPIR-V translator: widen sub-vec4 user fragment outputs to vec4 with explicit zero stores for the added components (the GL backends’ ExpandFragmentOutputsToVec4 AST pass is directly reusable from TranslatorSPIRV::translateImpl; this shape was build- and regression-tested). A per-component colorWriteMask derived from output sizes would mask attachment writes but leaves blend inputs undefined, so the translator-side fix is the complete one; secondary blend outputs (EXT_blend_func_extended) and framebuffer-fetch inouts should be audited for the same gap.


POC: ANGLE Vulkan — sub-vec4 WebGL fragment outputs leave attachment component words driver-undefined (readPixels-observable)

Strategy: web-content Bug Type: CWE-908 — use of uninitialized/indeterminate GPU values; web-observable info-leak channel (no memory corruption)

A WebGL2 fragment shader may legally declare a color output with fewer than 4 components (layout(location=0) out vec2 c;). ANGLE’s SPIR-V translator emits that output as a 2-component interface variable, and the Vulkan pipeline keeps a full RGBA colorWriteMask. Per Vulkan spec 15.3 “Fragment Output Interface”, “Component words which do not correspond to any fragment shader output will also result in undefined values for blending or color attachment writes” — so the driver writes driver-undefined values into the attachment’s B/A bytes, and gl.readPixels() hands them to web JavaScript. The Metal backend (ModifyStruct.cpp — “avoid indeterminate GPU register residue leaking into color attachments”) and the GL backends (ExpandFragmentOutputsToVec4) carry translator-level zero-fill fixes for this exact class; the SPIR-V/Vulkan pipeline has none.

What the POC does (poc.html)

Pure web content; no Chromium modification. It creates a WebGL2 context and runs five experiments, each drawing a full-screen triangle with a out vec2 c; c = vec2(0.25, 0.5); fragment shader into an RGBA8 FBO pre-cleared to the sentinel RGBA (51, 102, 153, 204), then reads back all pixels:

  • Test A (baseline): classifies the B/A bytes of every pixel: PRESERVED (still sentinel — destination untouched), CONST_ZERO/ CONST_255 (overwritten by a driver-chosen constant), or RESIDUE (any other value — data from nowhere in this draw).
  • Test B (same-context seeding): first draws a vec4 shader outputting bytes DE AD BE EF into a different FBO, then checks whether the seeded bytes reappear in the missing B/A words of the target draw.
  • Test C (cross-context seeding): same, but the seed is drawn from a second WebGL context.
  • Test E (same-shader register probe): the vec2-output shader computes a 4-component temporary from a “secret” uniform; checks whether the secret’s z/w bytes surface in the missing B/A words.
  • Test D (blend variant): enables SRC_ALPHA, ONE_MINUS_SRC_ALPHA blending so the undefined src-alpha word modulates the written R/G channels.

Results are printed on the page, POSTed to the server (results.json), and summarized in a verdict field.

Prerequisites

  • Chromium build with ANGLE (tested against an ASAN Linux build; any build works — the bug is not a memory error, the oracle is readPixels data).
  • A Vulkan ICD. Runs shown here: Mesa lavapipe (--use-angle=vulkan) and SwiftShader (--use-angle=swiftshader) — the only Vulkan devices in this GPU-less environment (/dev/dri does not exist).

Reproduction

cd <this directory>

# ANGLE Vulkan backend on the system Vulkan driver (lavapipe here):
./run_poc.sh /chromium_src/src/out/asan/chrome --disable-gpu-driver-bug-workarounds

# ANGLE Vulkan backend on SwiftShader:
./run_poc.sh /chromium_src/src/out/asan/chrome --disable-gpu-driver-bug-workarounds --use-angle=swiftshader

run_poc.sh serves the page on 127.0.0.1:8080, launches Chrome headful at http://127.0.0.1:8080/poc.html, waits for results.json, and prints it.

To regenerate the SPIR-V interface dumps with ANGLE’s own translator:

autoninja -C out/asan angle_shader_translator
printf '#version 300 es\nprecision mediump float;\nlayout(location=0) out vec2 c;\nvoid main() { c = vec2(0.25, 0.5); }\n' > /tmp/poc.frag
out/asan/angle_shader_translator -s=w2 -b=v -o /tmp/poc.frag   # WebGL2 spec, Vulkan/SPIR-V output

Flags and why they are environment accommodations, not security bypasses:

  • --use-angle=vulkan — selects the ANGLE Vulkan backend. This is the default-rollout configuration on Android (DefaultANGLEVulkan in testing/variations/fieldtrial_testing_config.json); on desktop it is the documented opt-in. The vulnerable code (the SPIR-V translator) is identical.
  • --disable-gpu-driver-bug-workarounds — without it, this software stack trips gpu_driver_bug_list entry 131 (disable_texture_storage, meant for Mesa < 10.6, from 2015), the only workaround capable of disabling ES3, and WebGL2 context creation fails with “ES3 is blocklisted/disabled/unsupported by driver”. Real hardware drivers (and Android, since the entry is os: linux) do not match it. Driver bug workarounds are functional accommodations, not security features.
  • --no-proxy-server, --user-data-dir, --no-first-run, --no-default-browser-check, --ignore-gpu-blocklist, --enable-logging=stderr — sandbox/environment plumbing.

Expected Result

On any Vulkan driver, Test A must show B/A not PRESERVED: the draw writes all four component words even though the shader only produces two — i.e. the two undefined component words reach the attachment and readPixels. What value they carry is, by spec, driver-dependent:

  • On the two software rasterizers available here (lavapipe, SwiftShader), the undefined words materialize as constant 0 (CONST_ZERO): their JIT-compiled shaders fold the undefined output lanes to zero. Verdict: UNDEFINED_WRITE_BENIGN_CONSTANT.
  • On hardware GPUs this same interface gap is the “Uninitialized Use in ANGLE” leak class (cross-origin data leaks; see e.g. CVE-2026-11109, CVE-2026-11110, CVE-2026-11123, CVE-2026-11138), and ANGLE’s own Metal fix describes the observed content as “indeterminate GPU register residue”. Expected verdicts there: LEAK_*. No GPU hardware exists in this environment, so that half is established by the SPIR-V evidence below plus the class precedent, not by direct observation.

Key artifacts

File What it proves
results-vulnerable-lavapipe.json, results-vulnerable-swiftshader.json End-to-end run on stock Chrome: R/G carry the shader’s values in 32768/32768 pixels; B/A sentinel is destroyed on every pixel (full-RGBA write mask with a vec2 output — the per-attachment missingOutputsMask and glColorMask layers do not intervene).
spirv-vulnerable.txt The SPIR-V ANGLE’s translator emits for the POC shader (via angle_shader_translator -s=w2 -b=v -o): the Location-0 output is OpVariable %_ptr_Output_v2float and the only store is a vec2 — attachment component words 2/3 satisfy Vulkan 15.3’s “do not correspond to any fragment shader output” clause verbatim.
spirv-fixed.txt Same shader after the bandaid fix: Location-0 output is v4float; components 2/3 receive explicit OpStore of 0.0 — no component word is left undefined.
bandaid-fix.patch The fix (see below).
chrome-vulnerable-lavapipe.log Chrome stderr for the lavapipe run (renderer string ANGLE (Mesa, Vulkan 1.3.230 (llvmpipe ...)) proving the Vulkan backend served the WebGL context).

Output

Stock (unpatched) Chrome, ANGLE Vulkan on lavapipe (results-vulnerable-lavapipe.json; identical classification on SwiftShader, results-vulnerable-swiftshader.json):

RENDERER: ANGLE (Mesa, Vulkan 1.3.230 (llvmpipe (LLVM 15.0.6 256 bits) (0x00000000)), llvmpipe)
Test A (vec2 -> RGBA8, sentinel-cleared):
  R/G carry shader values in 32768/32768 pixels
  B byte classes over 8 frames: {"CONST_ZERO":8} | top values: 0x32768
  A byte classes over 8 frames: {"CONST_ZERO":8} | top values: 0x32768
Test B (seed 0xbe/0xef via prior vec4 draw, same context):
  seeded-B byte in missing B: 0/32768 px | seeded-A byte in missing A: 0/32768 px
Test C (seed 0x5a/0xc3 drawn from a SECOND WebGL context):
  seeded-B byte in missing B: 0/32768 px | seeded-A byte in missing A: 0/32768 px
Test E (same-shader secret temp, secret z/w bytes 0x65/0x87):
  secret-z byte in missing B: 0/32768 px | secret-w byte in missing A: 0/32768 px
Test D (SRC_ALPHA blend; R=51 means alpha residue was 0, R=64 means 255, other = partial residue):
  R histogram: 51x4096
VERDICT: UNDEFINED_WRITE_BENIGN_CONSTANT

Reading of Test A: every one of the 32768 pixels has R=64, G=128 (the shader’s vec2) and B=0, A=0 — not the sentinel B=153, A=204 the FBO was cleared to. With only a vec2 fragment output, the draw still overwrote the B/A component words of the attachment (full-RGBA colorWriteMask; neither missingOutputsMask nor any validation intervened), exactly the Vulkan-15.3-undefined write. On these two CPU rasterizers the undefined words materialize as constant zeros (their JIT shader pipelines fold the missing output lanes to 0), so Tests B/C/E find no secret-byte carriage here; on hardware GPUs the same interface gap is ANGLE’s known register-residue leak class (see “Expected Result”).

SPIR-V interface proof (spirv-vulnerable.txt, emitted by ANGLE’s own translator for the exact POC shader under the WebGL2 spec):

OpDecorate %72 Location 0
%6  = OpTypeVector %5 2              ; v2float
%73 = OpTypePointer Output %6
%72 = OpVariable %73 Output          ; RelaxedPrecision, Location 0
...
OpStore %72 %82                      ; the only output write: a vec2

After the bandaid fix (spirv-fixed.txt):

%8  = OpTypeVector %5 4              ; v4float
%29 = OpTypePointer Output %8
%72 = OpVariable %29 Output          ; RelaxedPrecision, Location 0
%87 = OpConstant %5 0
...
OpStore %90 %87                      ; component 2 := 0.0
OpStore %92 %87                      ; component 3 := 0.0

Note: results-fixed-lavapipe.json is byte-identical to results-vulnerable-lavapipe.json — expected and verified: llvmpipe is deterministic and happens to render the undefined words as 0, which is the same value the fix defines them to. The fix’s effect is visible at the SPIR-V interface (above), which is the layer the Vulkan spec’s undefined clause keys on; on drivers whose “undefined” is not zero, the readPixels values change from residue to 0.

Bandaid fix

bandaid-fix.patch (apply in third_party/angle/) reuses the GL backends' ExpandFragmentOutputsToVec4 AST pass from the SPIR-V translator: it moves the pass into the GLSL+Vulkan shared source list and invokes it in TranslatorSPIRV::translateImpl for fragment shaders, right after EmulateFragColorData. Every sub-vec4 user color output is widened to vec4 with the added components stored as 0.0 at end-of-main — the exact mitigation shape of the Metal (ModifyStruct.cpp) and GL (ExpandFragmentOutputsToVec4 + expandFragmentOutputsToVec4 feature) fixes.

Verification performed with the patch applied (autoninja -C out/asan chrome angle_end2end_tests):

  • spirv-fixed.txt: interface variable becomes v4float, zero-stores added.
  • POC re-run on lavapipe: identical rendering (R/G = 64/128 everywhere, blend behavior unchanged) — no regression; B/A are now defined zeros.
  • ANGLE regression tests on the Vulkan backend: GLSLTest_ES3_Blend.*/ES3_Vulkan (the suite covering sub-vec4 outputs + blending semantics of this pass) — all pass; SimpleOperationTest.*/ES3_Vulkan + WebGL2GLSLTest.*/ES3_Vulkan — pass, except two SimpleOperationTest.DrawElementsLineLoop*ClientSideMemory failures that are unrelated (line-loop emulation; fail identically without the patch, see note below).

Scope note: the pass covers standard fragment outputs (EvqFragmentOut, scalars/vectors and arrays). Secondary blend outputs (EXT_blend_func_extended) and framebuffer-fetch inouts keep their declared sizes; a complete upstream fix should audit those variants too.

Notes

  • The leak is data-only; no memory corruption, so no ASAN report is expected at any point (ASAN cannot see GPU-side/driver values; on the software drivers used here the JIT-generated shader code is not instrumented).
  • The report’s blend variant is confirmed structurally: with SRC_ALPHA-based blending the undefined alpha word feeds the blend factor (Test D result R=51 = sentinel: the undefined alpha evaluated to 0 on these drivers, i.e. src*0 + dst*1).
  • WebGL1 cannot trigger this (ESSL1 outputs are vec4 gl_FragColor / gl_FragData); the POC requires WebGL2 (out vec2), matching the report.

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