CVE-2026-87596
Overview
Background
- ANGLE
- Chrome’s translation layer that converts WebGL/GLES shaders and calls into the host platform’s graphics API, including a SPIR-V backend for Vulkan.
- `RewriteStructSamplers`
- An ANGLE tree transformation that hoists sampler fields nested inside
structuniforms out into standalone top-level sampler uniforms. - SPIR-V ID assignment
- The
TranslatorSPIRVpass that walks the shader’s declarations and binds each uniform to a reflectionShaderVariableso itsidcan be recorded and reused during code generation. - `SymbolType::AngleInternal`
- A symbol classification marking names the translator synthesizes itself, as opposed to names written by the shader author.
Root Cause Analysis
When RewriteStructSamplers extracts a sampler from a struct uniform, the old TranslatorSPIRV code recovered the corresponding reflection ShaderVariable by reconstructing the extracted name string: FindUniformShaderVariable and its helper FindUniformFieldShaderVariable walked the uniform/field tree and compared the incoming symbol name against prefix + '_' + var.name built from the field path. This reconstruction is ambiguous, because the underscore separator collides with underscores that already appear in field names (for example a field literally named a_b versus a nested path a.b), so the lookup could resolve to the wrong ShaderVariable or fail entirely, in which case FindUniformShaderVariable fell through UNREACHABLE() and returned nullptr that was then dereferenced as &uniform->id. A mismatched or wrongly-matched variable causes an incorrect SPIR-V id to be assigned, and that bad id is subsequently used to index ID-keyed data during SPIR-V generation, yielding an out-of-bounds read.
The fix removes name-based matching entirely: GetSamplersInStructs collects the struct-nested samplers in DFS declaration order into a vector, and the declaration loop hands each AngleInternal sampler the next entry by index. This works because struct samplers are always active (never dead-code eliminated) and are never reordered, so their declaration order is guaranteed to match the reflection order, making the correspondence exact and collision-proof.
GetSamplersInStructs plus a running index), so the intended ShaderVariable is always selected.Attack Path
- Craft a colliding shader
An attacker authors a WebGL shader whose
structuniforms contain sampler fields whose names, combined with sibling field names, produce underscore-joined paths that alias each other. - Trigger extraction
Compiling to the Vulkan/SPIR-V backend runs
RewriteStructSamplers, hoisting the nested samplers into standalone uniforms named from those ambiguous paths. - Force a misresolved lookup
During SPIR-V ID assignment,
FindUniformShaderVariablematches the reconstructed name against the wrongShaderVariable(or fails and returns anullptr/UNREACHABLEpath). - Bind a bad ID
The mismatched
ShaderVariable::idis recorded for the sampler and later used as an index into ID-keyed SPIR-V state. - Read out of bounds
Code generation dereferences using the incorrect
id, reading memory outside the intended reflection/ID array bounds.
Impact Assessment
struct uniforms with sampler fields whose extracted names collide; no special privileges beyond loading attacker-controlled WebGL content are required. The disclosed data is confined to whatever the mis-indexed read exposes during translation, but as a memory-safety flaw in the GPU process it is rated high severity.Files Changed
include/GLSLANG/ShaderLang.hsamples/shader_translator/shader_translator.cppsrc/compiler/translator/spirv/TranslatorSPIRV.cppsrc/compiler/translator/tree_ops/RewriteStructSamplers.cpp
Audit Directions
- Reconstructed/delimiter-joined identifiersFlag any code that recovers an object by rebuilding a name from a path joined with a separator (
_,.,[]); if the separator can appear inside a component, the mapping is not invertible and can alias. - Lookup results feeding indicesWherever a name or reflection lookup can return
nullptror fall throughUNREACHABLE(), verify the caller does not dereference or use the result (such as anid) as an array index without checking. - Order-dependent invariantsAudit transformations that assume samplers/variables are always active and never reordered; if a future pass eliminates or reorders them, the DFS-index correspondence in
GetSamplersInStructssilently breaks.
Patch
From c553dab3a7b9599df176aa0f166c7d52e75bfed3 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Tue, 21 Jul 2026 16:58:28 -0400
Subject: [PATCH] Translator: Fix name collision in extracted samplers
Instead of generating a name out of the path from the uniform that leads
to the sampler, a name is generated for these samplers with the
AngleInternal symbol so it cannot collide with other shader names.
Currently, samplers in structs are always considered active (so won't be
dead-code eliminated). No transformation reorders them either. These
two properties are now requirements of this transformation.
Bug: angleproject:42262930
Bug: chromium:536434693
Change-Id: I2b64bef23ceaa0c03615eb10035aecf491d78111
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/8133266
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/GLSLANG/ShaderLang.h b/include/GLSLANG/ShaderLang.h
index f663f88..6b68b7e 100644
--- a/include/GLSLANG/ShaderLang.h
+++ b/include/GLSLANG/ShaderLang.h
@@ -26,7 +26,7 @@
// Version number for shader translation API.
// It is incremented every time the API changes.
-#define ANGLE_SH_VERSION 419
+#define ANGLE_SH_VERSION 420
enum ShShaderSpec
{
@@ -1004,6 +1004,10 @@
EnumCount = InvalidEnum,
};
+// If samplers are extracted from structs, their names will be <prefix><N>, where <N> is a
+// zero-based index assigned in DFS-order of declaration.
+extern const char kExtractedSamplerNamePrefix[];
+
namespace vk
{
diff --git a/samples/shader_translator/shader_translator.cpp b/samples/shader_translator/shader_translator.cpp
index a6e277b..78ca6ec 100644
--- a/samples/shader_translator/shader_translator.cpp
+++ b/samples/shader_translator/shader_translator.cpp
@@ -114,6 +114,9 @@
case 'u':
printActiveVariables = true;
break;
+ case 'g':
+ compileOptions.outputDebugInfo = true;
+ break;
case 'r':
compileOptions.useIR = true;
break;
@@ -180,6 +183,7 @@
break;
case 'v':
output = SH_SPIRV_VULKAN_OUTPUT;
+ compileOptions.emitSPIRV14 = true;
compileOptions.initializeUninitializedLocals = true;
compileOptions.removeInactiveVariables = true;
break;
@@ -433,6 +437,7 @@
" -i : print intermediate tree\n"
" -o : print translated code\n"
" -u : print active attribs, uniforms, varyings and program outputs\n"
+ " -g : include debug info in the output\n"
" -r : use the IR instead of AST\n"
" -s=e2 : use GLES2 spec (this is by default)\n"
" -s=e3 : use GLES3 spec\n"
diff --git a/src/compiler/translator/spirv/TranslatorSPIRV.cpp b/src/compiler/translator/spirv/TranslatorSPIRV.cpp
index 78f3d90..6f542a0 100644
--- a/src/compiler/translator/spirv/TranslatorSPIRV.cpp
+++ b/src/compiler/translator/spirv/TranslatorSPIRV.cpp
@@ -556,52 +556,29 @@
return nullptr;
}
-ShaderVariable *FindUniformFieldShaderVariable(std::vector<ShaderVariable> *vars,
- const ImmutableString &name,
- const char *prefix)
+void GetSamplersInStruct(std::vector<ShaderVariable> *fields, TVector<ShaderVariable *> *samplers)
{
- for (ShaderVariable &var : *vars)
+ for (ShaderVariable &var : *fields)
{
- // The name of the sampler is derived from the uniform name + fields
- // that reach the uniform, concatenated with '_' per RewriteStructSamplers.
- std::string varName = prefix;
- varName += '_';
- varName += var.name;
-
- if (name == varName)
+ if (gl::IsSamplerType(var.type))
{
- return &var;
+ samplers->push_back(&var);
}
-
- ShaderVariable *field = FindUniformFieldShaderVariable(&var.fields, name, varName.c_str());
- if (field != nullptr)
+ else
{
- return field;
+ GetSamplersInStruct(&var.fields, samplers);
}
}
- return nullptr;
}
-ShaderVariable *FindUniformShaderVariable(std::vector<ShaderVariable> *vars,
- const ImmutableString &name)
+TVector<ShaderVariable *> GetSamplersInStructs(std::vector<ShaderVariable> *vars)
{
+ TVector<ShaderVariable *> samplers;
for (ShaderVariable &var : *vars)
{
- if (name == var.name)
- {
- return &var;
- }
-
- // Note: samplers in structs are moved out. Such samplers will be found in the fields of
- // the struct uniform.
- ShaderVariable *field = FindUniformFieldShaderVariable(&var.fields, name, var.name.c_str());
- if (field != nullptr)
- {
- return field;
- }
+ GetSamplersInStruct(&var.fields, &samplers);
}
- UNREACHABLE();
- return nullptr;
+ return samplers;
}
void SetSpirvIdInFields(uint32_t id, std::vector<ShaderVariable> *fields)
@@ -1224,6 +1201,12 @@
// of this fact for optimal hashing.
mFirstUnusedSpirvId = vk::spirv::kIdFirstUnreserved;
+ // Extracted samplers are given generic names and cannot be looked up. They are given IDs in
+ // sequence based on declaration order, which also means they cannot be dead-code eliminated or
+ // reordered by any transformation
+ TVector<ShaderVariable *> extractedSamplers = GetSamplersInStructs(&mUniforms);
+ uint32_t nextExtractedSampler = 0;
+
for (TIntermNode *node : *root->getSequence())
{
TIntermDeclaration *decl = node->getAsDeclarationNode();
@@ -1291,8 +1274,22 @@
}
else if (qualifier == EvqUniform)
{
- ShaderVariable *uniform = FindUniformShaderVariable(&mUniforms, symbol->getName());
- variableId = &uniform->id;
+ // The translator never adds any samplers that are not declared in the shader. As such,
+ // the only |AngleInternal| samplers are those that are extracted from uniforms.
+ if (IsSampler(type.getBasicType()) &&
+ symbol->variable().symbolType() == SymbolType::AngleInternal)
+ {
+ // Since the samplers are declared in the shader in the same order as they are
+ // collected in reflection info, pick the next |ShaderVariable| for these samplers.
+ ASSERT(nextExtractedSampler < extractedSamplers.size());
+ variableId = &extractedSamplers[nextExtractedSampler]->id;
+ ++nextExtractedSampler;
+ }
+ else
+ {
+ ShaderVariable *uniform = FindShaderVariable(&mUniforms, symbol->getName());
+ variableId = &uniform->id;
+ }
}
else if (qualifier == EvqAttribute || qualifier == EvqVertexIn)
{
diff --git a/src/compiler/translator/tree_ops/RewriteStructSamplers.cpp b/src/compiler/translator/tree_ops/RewriteStructSamplers.cpp
index 77262ed..ee8dc88 100644
--- a/src/compiler/translator/tree_ops/RewriteStructSamplers.cpp
+++ b/src/compiler/translator/tree_ops/RewriteStructSamplers.cpp
@@ -20,9 +20,10 @@
namespace sh
{
+const char kExtractedSamplerNamePrefix[] = "extractedSampler";
+
namespace
{
-
// Used to map one structure type to another (one where the samplers are removed).
struct StructureData
{
@@ -189,7 +190,7 @@
const bool isSampler = node->getType().isSampler();
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 57e276e..f8fb4c5 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -557,6 +557,7 @@
526451592 WIN D3D11 : ImageTestES3.NonZeroLevelCopyTexImageSrcDst/* = SKIP
526451592 WIN D3D11 : ImageTestES3.NonZeroLevelTexSubImageDstWithUnpackSlow/* = SKIP
526451592 WIN D3D11 : ImageTestES3.NonZeroLevelBlitDst/* = SKIP
+537801203 WIN D3D11 : GLSLTest.SamplersInStructNoNameCollision/* = SKIP
// Android
42264624 ANDROID GLES : GLSLTest_ES3.InitGlobalComplexConstant/* = SKIP
@@ -2402,6 +2403,7 @@
42267100 WGPU : GLSLTest.StructsWithSamplersAsFunctionArgWithPrototype/* = SKIP
42267100 WGPU : GLSLTest.ArrayOfStructsWithSamplersAsFunctionArg/* = SKIP
42267100 WGPU : GLSLTest.StructWithSamplerArrayAsFunctionArg/* = SKIP
+42267100 WGPU : GLSLTest.SamplersInStructNoNameCollision2/* = SKIP
42267100 WGPU : GLSLTest.VectorScalarDivideAndAddInLoop/* = SKIP
42267100 WGPU : GLSLTest.PointCoordConsistency/* = SKIP
42267100 WGPU : GLSLTest.ScreenFlipCauseStandardDerivativesWrong/* = SKIP
diff --git a/src/tests/compiler_tests/WGSLOutput_test.cpp b/src/tests/compiler_tests/WGSLOutput_test.cpp
index 0f6a9fc..d48e077 100644
--- a/src/tests/compiler_tests/WGSLOutput_test.cpp
+++ b/src/tests/compiler_tests/WGSLOutput_test.cpp
@@ -2123,8 +2123,8 @@
@group(1) @binding(@@@@@@) var ANGLE_texture_samp3D : texture_3d<f32>;
@group(1) @binding(@@@@@@) var ANGLE_sampler_sampCube : sampler;
@group(1) @binding(@@@@@@) var ANGLE_texture_sampCube : texture_cube<f32>;
-@group(1) @binding(@@@@@@) var ANGLE_sampler_sampStruct_samp1 : sampler;
-@group(1) @binding(@@@@@@) var ANGLE_texture_sampStruct_samp1 : texture_2d<f32>;
+@group(1) @binding(@@@@@@) var ANGLE_sampler_extractedSampler0 : sampler;
+@group(1) @binding(@@@@@@) var ANGLE_texture_extractedSampler0 : texture_2d<f32>;
struct ANGLEDepthRangeParams
{
@@ -2153,7 +2153,7 @@
fn _umain()
{
- (ANGLE_output_global._ufragColor) = (textureSample(ANGLE_texture_sampStruct_samp1, ANGLE_sampler_sampStruct_samp1, (vec2<f32>(0.0f, 0.0f)).xy));
+ (ANGLE_output_global._ufragColor) = (textureSample(ANGLE_texture_extractedSampler0, ANGLE_sampler_extractedSampler0, (vec2<f32>(0.0f, 0.0f)).xy));
(ANGLE_output_global._ufragColor) += (textureSample(ANGLE_texture_samp2D, ANGLE_sampler_samp2D, (vec2<f32>(0.0f, 0.0f)).xy));
(ANGLE_output_global._ufragColor) += (textureSample(ANGLE_texture_samp3D, ANGLE_sampler_samp3D, (vec3<f32>(0.0f, 0.0f, 0.0f)).xyz));
(ANGLE_output_global._ufragColor) += (textureSample(ANGLE_texture_sampCube, ANGLE_sampler_sampCube, (vec3<f32>(0.0f, 0.0f, 0.0f)).xyz));
diff --git a/src/tests/gl_tests/GLSLTest.cpp b/src/tests/gl_tests/GLSLTest.cpp
index e91b6a4..e9f188a 100644
--- a/src/tests/gl_tests/GLSLTest.cpp
+++ b/src/tests/gl_tests/GLSLTest.cpp
@@ -3083,16 +3083,15 @@
// Test that array of structs containing array of samplers work as expected.
TEST_P(GLSLTest, ArrayOfStructContainingArrayOfSamplers)
{
- constexpr char kFS[] =
- "precision mediump float;\n"
- "struct Data { mediump sampler2D data[2]; };\n"
- "uniform Data test[2];\n"
- "void main() {\n"
- " gl_FragColor = vec4(texture2D(test[1].data[1], vec2(0.0, 0.0)).r,\n"
- " texture2D(test[1].data[0], vec2(0.0, 0.0)).r,\n"
- " texture2D(test[0].data[1], vec2(0.0, 0.0)).r,\n"
- " texture2D(test[0].data[0], vec2(0.0, 0.0)).r);\n"
- "}\n";
+ constexpr char kFS[] = R"(precision mediump float;
+struct Data { mediump sampler2D data[2]; };
+uniform Data test[2];
+void main() {
+ gl_FragColor = vec4(texture2D(test[1].data[1], vec2(0.0, 0.0)).r,
+ texture2D(test[1].data[0], vec2(0.0, 0.0)).r,
+ texture2D(test[0].data[1], vec2(0.0, 0.0)).r,
+ texture2D(test[0].data[0], vec2(0.0, 0.0)).r);
+})";
ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), kFS);
glUseProgram(program);
@@ -12062,8 +12061,62 @@
})";
ANGLE_GL_PROGRAM(program, kVS, kFS);
-
drawQuad(program, "a_position", 0.5f);
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::transparentBlack);
+ ASSERT_GL_NO_ERROR();
+}
+
+// Test that samplers in structs don't create name collisions if extracted.
+TEST_P(GLSLTest, SamplersInStructNoNameCollision)
+{
+ constexpr char kVS[] = R"(attribute vec4 a_position;
+void main() {
+ gl_Position = a_position;
+})";
+
+ constexpr char kFS[] = R"(precision highp float;
+struct S
+{
+ sampler2D s;
+};
+uniform struct A
+{
+ vec4 v;
+ S a;
+ sampler2D a_s;
+} a;
+void main() {
+ gl_FragColor = a.v;
+})";
+
+ ANGLE_GL_PROGRAM(program, kVS, kFS);
+ drawQuad(program, "a_position", 0.5f);
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::transparentBlack);
+ ASSERT_GL_NO_ERROR();
+}
+
+// Test that samplers in structs don't create name collisions if extracted.
+TEST_P(GLSLTest, SamplersInStructNoNameCollision2)
+{
+ constexpr char kVS[] = R"(attribute vec4 a_position;
+void main() {
+ gl_Position = a_position;
+})";
+
+ constexpr char kFS[] = R"(precision mediump float;
+struct A { sampler2D b_t; };
+struct B { sampler2D t; };
+uniform A a[4];
+uniform B a_b[2];
+void main()
+{
+ gl_FragColor = texture2D(a[3].b_t, vec2(0.0)) + texture2D(a_b[1].t, vec2(0.0));
+})";
+
+ ANGLE_GL_PROGRAM(program, kVS, kFS);
+ drawQuad(program, "a_position", 0.5f);
+ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::black);
+ ASSERT_GL_NO_ERROR();
}
// Helper functions for MixedRowAndColumnMajorMatrices* tests
Original Bug Report
OOB OpAccessChain in ANGLE Vulkan via RewriteStructSamplers Name Collision
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: A name collision in ANGLE’s RewriteStructSamplers pass can silently overwrite extracted samplers in release builds due to a debug-only ASSERT. This allows an attacker to bypass AST bounds checks, emitting an out-of-bounds OpAccessChain index into the SPIR-V module. The invalid SPIR-V reaches the Vulkan driver unvalidated, potentially causing out-of-bounds descriptor reads and memory corruption in the GPU process.
Affected files:
third_party/angle/src/compiler/translator/tree_ops/RewriteStructSamplers.cppthird_party/angle/src/compiler/translator/spirv/OutputSPIRV.cpp
Estimated timestamp from git blame: 2021-01-14
1. Summary of the Issue (Meant for Human Triage)
The ANGLE compiler contains a logic flaw in the RewriteStructSamplers tree-op pass, which is responsible for extracting nested samplers from ESSL 1.00 structures and rewriting them as individual globally defined sampler uniforms. When generating names for these extracted samplers, the compiler concatenates the parent uniform’s name with the nested field-name path. Distinct uniform structures with varying array sizes and sampler types can legally produce the exact same concatenated identifier.
To prevent these name collisions, ANGLE relies on a debug-only ASSERT(). In release builds (where NDEBUG is defined and ANGLE_ENABLE_ASSERTS is unset), this assertion evaluates to a no-op, causing the compiler to silently overwrite previously defined variables in its mExtractedSamplers hash map.
Consequently, during the AST traversal phase, the compiler may substitute a smaller array variable into an index operation originally intended for a larger array. Because ANGLE does not perform boundary checks on direct constant indices when reconstructing the AST binary nodes, this out-of-bounds index is directly translated into the final SPIR-V module as an invalid OpAccessChain instruction. Because downstream SPIR-V validation calls are also wrapped in debug-only ASSERTs, the spec-invalid SPIR-V blob (violating VUID-RuntimeSpirv-None-09403) is passed directly to the host driver via vkCreateShaderModule. Vulkan’s robustBufferAccess and robustImageAccess protections do not cover out-of-bounds indexing of descriptor arrays, meaning the GPU driver will fetch a descriptor from an out-of-bounds memory location. This is reachable from unprivileged WebGL content and results in a potential High severity memory corruption (OOB descriptor read) within the Vulkan backend, which runs in the unsandboxed GPU process on Android.
2. Proof-of-Concept & Detailed Execution Flow
Note: The following steps describe a potential attack sequence and execution flow. Our tooling agent has statically traced this path from source but does not currently run the code dynamically.
Step 1: Shader Parsing and Prefix Construction
A malicious web page creates a WebGL 1 context and calls gl.shaderSource() with an ESSL 1.00 shader containing two structures:
struct A { sampler2D b_t; }; uniform A a[10];struct B { sampler2D t; }; uniform B a_b[2];
The shader references the arrays, e.g., texture2D(a[7].b_t, v). The RewriteStructSamplersTraverser executes a pre-order traversal (RewriteStructSamplers.cpp:282), processing the declarations. In extractFieldSamplers, it concatenates the uniform prefix with the field name: std::string newPrefix = prefix + "_" + field->name().data(); (RewriteStructSamplers.cpp:535). For a and b_t, this yields "a_b_t".
Step 2: Silent Map Overwrite in Release Builds
extractSampler generates a sampler2D[10] TVariable and stores it at mExtractedSamplers["a_b_t"]. It uses a debug-only assertion to check for collisions: ASSERT(mExtractedSamplers.find(newName) == mExtractedSamplers.end()); (RewriteStructSamplers.cpp:600).
The traverser then processes uniform B a_b[2]. It similarly constructs the string "a_b_t" (from "a_b" + "_" + "t") and generates a sampler2D[2] variable. Because ANGLE_ENABLE_ASSERTS is unset in release builds, the ASSERT at line 600 evaluates to a no-op (third_party/angle/src/common/log_utils.h:209-211). The map silently overwrites the entry: mExtractedSamplers["a_b_t"] = newVariable; (RewriteStructSamplers.cpp:601), replacing sampler2D[10] with sampler2D[2].
Step 3: AST Expression Corruption
The traverser descends into the main() function and visits the AST node a[7].b_t. RewriteModifiedStructFieldSelectionExpression intercepts this expression, rebuilding the string "a_b_t" (RewriteStructSamplers.cpp:209-217). It performs a map lookup: rewritten = new TIntermSymbol(extractedSamplers.at(samplerName)); (RewriteStructSamplers.cpp:229). Due to the silent overwrite, rewritten now references sampler2D[2].
The AST expression is rebuilt using the original, un-clamped index: rewritten = new TIntermBinary(EOpIndexDirect, rewritten, indexNode->getRight()); (RewriteStructSamplers.cpp:254), where indexNode->getRight() is the constant 7.
Step 4: Absence of Index Bounds Validation
The TIntermBinary constructor calls promote(). For arrays, promote() calls mType.toArrayElementType() which strips one array dimension but performs absolutely no index-vs-bounds validation (IntermNode.cpp:1900-1918). The ClampIndirectIndices pass explicitly ignores EOpIndexDirect (ClampIndirectIndices.cpp:38), meaning the constant [7] is left un-clamped.
Step 5: OOB SPIR-V OpAccessChain Emission
OutputSPIRVTraverser intercepts the invalid EOpIndexDirect node (OutputSPIRV.cpp:5204). It extracts the constant index 7 and pushes it directly into the access chain: accessChainPushLiteral(&mNodeData.back(), spirv::LiteralInteger(index), typeId); (OutputSPIRV.cpp:5212). This function unconditionally appends the index to data->idList (OutputSPIRV.cpp:857).
OutputSPIRVTraverser::accessChainCollapse resolves the expression, triggering spirv::WriteAccessChain (OutputSPIRV.cpp:970-974). An OpAccessChain is emitted using the constant index %int_7 into a variable declared as OpTypeArray %simg 2.
Step 6: Driver-Level Unvalidated Execution
At the end of translation, SPIR-V validation is skipped because it is wrapped in ASSERT(spirv::Validate(result)); (OutputSPIRV.cpp:6702). The Vulkan backend retrieves the SPIR-V blob during program linking and runs it through transformations (ProgramExecutableVk.cpp:631), followed by another skipped debug-only validation ASSERT(spirv::Validate(*spirvBlobOut)); (spv_utils.cpp:6201).
The unverified blob is passed directly to the host driver via vkCreateShaderModule (vk_wrapper.h:1927). Vulkan’s robustBufferAccess and robustImageAccess do not protect descriptor array indices. When executed, the GPU driver will perform an out-of-bounds read at descriptor array element 7 of the 2-element UniformConstant array, fetching an arbitrary memory location to use as a texture descriptor.
Suggested Fix:
Instead of generating names using standard string concatenation based on ESSL identifiers (newName = prefix + "_" + field->name().data();), ANGLE should assign unique, collision-proof internal identifiers or suffixes (e.g., using a monotonically increasing counter or angle::Compiler’s internal symbol ID generator). The TODO at RewriteStructSamplers.cpp:596 explicitly states: “Use a temp name instead of generating a name as currently done. There is no guarantee that these generated names cannot clash.” Resolving this TODO will prevent the silent map overwrite entirely.
3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)
> Verbatim Critic Model Verdict (2026-07-18 04:42:22)
> Probability assessment: 100% probability the vulnerability is real and the details are correct. The code at HEAD in RewriteStructSamplers.cpp:600-601 contains the unmitigated map overwrite in release builds (only protected by a debug-only ASSERT). The steps to produce a spec-invalid SPIR-V module are accurately traced to OutputSPIRV.cpp where array bounds are not checked for EOpIndexDirect, resulting in an OOB OpAccessChain. The probability of exploitation by an adversary to achieve driver-level UB or memory corruption via out-of-bounds descriptor reads is very high (95%+).
>
> Severity Justification: S1 (High)
> 1. Direct Match with S1 Guidelines: The vulnerability directly matches the S1 indicator: ‘ANGLE shader translator emits SPIR-V/HLSL with an OOB OpAccessChain / array index from attacker GLSL… The emitted IR reaches vkCreateShaderModule… in the GPU process. This is web-content-reachable’.
> 2. GPU Process & Sandbox Environment: The vulnerability is web-content-reachable (A-SERVER) without requiring a compromised renderer. The Vulkan backend is the default on Android, where the GPU process is completely unsandboxed.
> 3. Validating-Layer Gap & Spec Invalidity: The ANGLE validating-layer fails to reject the OOB index because ClampIndirectIndices only applies to EOpIndexIndirect, and AST/SPIR-V validation passes are ASSERT-wrapped (debug-only). The resulting SPIR-V module fails Vulkan Validation Layers due to the OOB OpAccessChain index, proving it is spec-invalid (violating SPIR-V and Vulkan VUIDs) and is passed to the driver’s vkCreateShaderModule.
> 4. Read vs Write Primitive: The resulting primitive is an OpImageSampleImplicitLod from an out-of-bounds descriptor. Because a clear write primitive is not demonstrated in the report, it is capped at High (S1) and does not escalate to Critical (S0) per the ‘Read primitive / requires renderer compromise → S1’ ceiling.
> 5. Why not S2?: The S2 downgrade for ‘GPU-memory-resident OOB read only’ does not apply. An OOB descriptor read is fundamentally different from a GPU-memory-resident read (like sampling OOB pixels); it causes the driver to misinterpret arbitrary memory as a descriptor, which can lead to arbitrary memory access or driver crashes, not just limited metadata disclosure. Furthermore, the explicit S1 rule for SPIR-V/HLSL OOB OpAccessChain overrides this. The S2 downgrade for Metal-only output also does not apply since this targets the Vulkan backend (SPIR-V).
> 6. MiraclePtr / Hardening: MiraclePtr (BRP) does not apply because the ANGLE AST uses a pool allocator (arena), not raw_ptr managed heap memory. There are no other downstream release-build defenses that catch this.
Additional Verification Log (Codebase Investigator):
- ASSERT Definition (
third_party/angle/src/common/log_utils.h:209-211): ConfirmedASSERTevaluates toANGLE_EAT_STREAM_PARAMETERS << !(condition)using a ternary operator whenANGLE_ENABLE_ASSERTSis disabled (release mode), short-circuiting and bypassing the evaluation entirely. - ValidateAST bypass (
third_party/angle/src/libANGLE/Shader.cpp:669-671):options.validateAST = true;is#if defined(ANGLE_ENABLE_ASSERTS)gated, skipping AST verification in release builds. - Vulkan Validation Layer Compliance (VUID-RuntimeSpirv-None-09403): Confirmed that
OpAccessChainindices explicitly exceedingOpTypeArraybounds violates this spec constraint. - Robustness Limitations: Confirmed
third_party/vulkan-validation-layers/src/docs/gpu_av_descriptor_indexing.mddocuments thatrobustBufferAccessandrobustImageAccessdo NOT protect out-of-bounds descriptor array access, ensuring the maliciousOpImageSampleImplicitLodexecutes unmitigated. - Execution trace mapping: Confirmed
OutputSPIRVTraverser::createImageTextureBuiltIn(OutputSPIRV.cpp:3444) fetchesspv::DimviagetImageTypeParameters, ultimately mapped down to anOpImageSampleImplicitLodread primitive driven by the corrupted OOB variable.
Evaluated with Chrome root at commit: b96d2ec58f4f5f92b540a723966b199d6e9951b4
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.