CVE-2026-113143
Overview
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.)
Attack Path
- ENTRY Supply a WebGL fragment shader (ESSL 3.00+) with a non-vec4 output on a hardened context.
- CONTROLLED INPUT Leave a component (e.g. alpha) unwritten so it holds an undefined value.
- MISSING CHECK Without the expansion, the undefined component is not zero-filled before use.
- 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
Files Changed
src/compiler/translator/ir/src/transform.rssrc/libANGLE/renderer/gl/ShaderGL.cppsrc/libANGLE/renderer/gl/renderergl_utils.cppinclude/platform/autogen/FeaturesGL_autogen.hutil/autogen/angle_features_autogen.cppsrc/compiler.gnisrc/tests/angle_end2end_tests_expectations.txtsrc/compiler/translator/Compiler.cppsrc/compiler/translator/ir/src/transform/expand_fragment_outputs_to_vec4.rssrc/compiler/translator/glsl/TranslatorGLSL.cppsrc/compiler/translator/ir/src/ir.rssrc/tests/gl_tests/WebGLCompatibilityTest.cppsrc/compiler/translator/ir/src/transform/emulate_multi_draw.rssrc/compiler/translator/glsl/TranslatorESSL.cppsrc/compiler/translator/ir/BUILD.gnsrc/libANGLE/Shader.cpputil/autogen/angle_features_autogen.hsrc/compiler/translator/tree_ops/glsl/ExpandFragmentOutputsToVec4.hsrc/compiler/translator/tree_ops/ExpandFragmentOutputsToVec4.cppsrc/compiler/translator/ir/src/compile.rssrc/compiler/translator/ir/src/compile.cppsrc/tests/gl_tests/GLSLTest.cppsrc/compiler/translator/tree_ops/ExpandFragmentOutputsToVec4.hinclude/platform/gl_features.jsonsrc/compiler/translator/ir/src/transform/emulate_instanced_multiview.rs
Audit Directions
- Undefined output componentsAudit 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 generalReview other
Workaroundsflags that guard undefined-value behavior for cases that should apply to all backends.
Patch
From 4979a49c3fdb872fcac93c6bfc02480ef14c5faf Mon Sep 17 00:00:00 2001 From: Shahbaz Youssefi <[email protected]> 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 <[email protected]> Reviewed-by: Geoff Lang <[email protected]> Commit-Queue: Shahbaz Youssefi <[email protected]> --- 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)
Regression Test / PoC
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,
Original Bug Report
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:
third_party/angle/src/compiler/translator/spirv/TranslatorSPIRV.cpp:939-960— fragment-output walk handles onlygl_SampleMask/gl_SecondaryFragDataEXT/yuv; user color outputs are never size-normalizedthird_party/angle/src/compiler/translator/spirv/BuildSPIRV.cpp:1895-1938,2168-2206— emits theOpVariableat declared size (v2float) with only a Location decorationthird_party/angle/src/compiler/translator/msl/ModifyStruct.cpp:885-896— reference: Metal backend’s zero-fill fix for the same classthird_party/angle/src/compiler/translator/tree_ops/glsl/ExpandFragmentOutputsToVec4.cpp:39-96— reference: GL backends’ widen-to-vec4 pass (no caller in the SPIR-V path)third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp:4731-4745,third_party/angle/src/libANGLE/renderer/vulkan/vk_cache_utils.cpp:4249-4258— missing-output write masking is per-draw-buffer only, never per-component
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 —
DefaultANGLEVulkanis 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
- 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. - 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-1938then declaresOpVariable %_ptr_Output_v2float, andBuildSPIRV.cpp:2168-2206adds only a Location decoration. Pipeline-time SPIR-V transforms (renderer/vulkan/spv_utils.cpp:415-448) preserve output sizes. - WebGL output zero-init cannot reach the missing words:
renderer/vulkan/ShaderVk.cpp:38-41enablesinitOutputVariablesfor WebGL, which insertsc = 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. - Write masking is per-attachment, not per-component:
ContextVk.cpp:4731-4745computesmissingOutputsMask = ~shaderOutMask & framebufferMaskovergl::DrawBufferMask(one bit per draw buffer). An activevec2output keeps its draw buffer’s bit clear, sovk_cache_utils.cpp:4249-4258leavescolorWriteMaskat full RGBA; masks are never derived from output component counts. - 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 thatgl.readPixels()returns to JavaScript. This violates WebGL’s Resource Restrictions guarantee that shaders cannot access uninitialized data.
Reproduction
- Open a page in Chrome with the ANGLE Vulkan backend (Android
DefaultANGLEVulkanpopulation, or desktop--use-angle=vulkan) and create a WebGL2 context. - 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); }. - Create an RGBA8 FBO and clear it to a sentinel color (e.g. RGBA 51,102,153,204) to distinguish “preserved” from “overwritten”.
gl.drawArrays(gl.TRIANGLES, 0, 3)— all validation passes; pipeline write mask is RGBA.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.- 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), orRESIDUE(any other value — data from nowhere in this draw). - Test B (same-context seeding): first draws a vec4 shader outputting
bytes
DE AD BE EFinto 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_ALPHAblending 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/dridoes 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 (DefaultANGLEVulkanintesting/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 forMesa < 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 isos: 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 becomesv4float, 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 twoSimpleOperationTest.DrawElementsLineLoop*ClientSideMemoryfailures 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 resultR=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.
- http://127.0.0.1:8080/poc.html
- https://source.chromium.org/chromium/chromium/src/+/main:renderer/vulkan/ShaderVk.cpp;l=38
- https://source.chromium.org/chromium/chromium/src/+/main:renderer/vulkan/spv_utils.cpp;l=415
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/compiler/translator/msl/ModifyStruct.cpp;l=885
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/compiler/translator/spirv/BuildSPIRV.cpp;l=1895
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/compiler/translator/spirv/TranslatorSPIRV.cpp;l=939
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/compiler/translator/tree_ops/glsl/ExpandFragmentOutputsToVec4.cpp;l=39
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp;l=4731
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/vk_cache_utils.cpp;l=4249
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/validationES.cpp;l=445