CVE-2026-14420
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/dawn/native/opengl/ShaderModuleGL.cpp |
modified | |
OpArrayLengthVisibilityCollisionTestsrc/dawn/tests/end2end/OpArrayLengthTests.cpp |
modified | |
TEST_Psrc/dawn/tests/end2end/OpArrayLengthTests.cpp |
modified |
Files Changed
src/dawn/native/opengl/ShaderModuleGL.cppsrc/dawn/tests/end2end/OpArrayLengthTests.cpp
Patch
From 73591422f504902c597a67beaeb8e22101f030bd Mon Sep 17 00:00:00 2001
From: Stephen White <senorblanco@chromium.org>
Date: Tue, 02 Jun 2026 13:24:30 -0700
Subject: [PATCH] GL: add missing stage visibility check
In GenerateArrayLengthFromUniformData(), skip bindings that are
not visible to the current stage.
Bug: 517031505
Change-Id: Ida25ca9233a6880461f67a177e9d2257c1727094
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/313155
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Stephen White <senorblanco@chromium.org>
---
diff --git a/src/dawn/native/opengl/ShaderModuleGL.cpp b/src/dawn/native/opengl/ShaderModuleGL.cpp
index 1a8687b..f5e304a 100644
--- a/src/dawn/native/opengl/ShaderModuleGL.cpp
+++ b/src/dawn/native/opengl/ShaderModuleGL.cpp
@@ -273,6 +273,7 @@
bool GenerateArrayLengthFromuniformData(
const BindingInfoArray& moduleBindingInfo,
const PipelineLayout* layout,
+ SingleShaderStage stage,
tint::glsl::writer::ArrayLengthFromUniformOptions& options) {
const PipelineLayout::BindingIndexInfo& indexInfo = layout->GetBindingIndexInfo();
@@ -282,6 +283,11 @@
for (BindingIndex binding : bgl->GetBufferIndices()) {
const BindingInfo& bindingInfo = bgl->GetBindingInfo(binding);
+ // Skip bindings that aren't visible to this stage.
+ if (!(bindingInfo.visibility & StageBit(stage))) {
+ continue;
+ }
+
switch (std::get<BufferBindingInfo>(bindingInfo.bindingLayout).type) {
case wgpu::BufferBindingType::Storage:
case kInternalStorageBufferBinding:
@@ -409,7 +415,7 @@
if (GetDevice()->IsToggleEnabled(Toggle::GLUseArrayLengthFromUniform)) {
*needsSSBOLengthUniformBuffer = GenerateArrayLengthFromuniformData(
- moduleBindingInfo, layout, req.tintOptions.array_length_from_uniform);
+ moduleBindingInfo, layout, stage, req.tintOptions.array_length_from_uniform);
if (*needsSSBOLengthUniformBuffer) {
req.tintOptions.use_array_length_from_uniform = true;
req.tintOptions.array_length_from_uniform.ubo_binding = {
diff --git a/src/dawn/tests/end2end/OpArrayLengthTests.cpp b/src/dawn/tests/end2end/OpArrayLengthTests.cpp
index ea36ca3..a96a58d 100644
--- a/src/dawn/tests/end2end/OpArrayLengthTests.cpp
+++ b/src/dawn/tests/end2end/OpArrayLengthTests.cpp
@@ -303,6 +303,93 @@
VulkanBackend(),
WebGPUBackend());
+// Regression test for stage-visibility filtering in
+// GenerateArrayLengthFromuniformData (ShaderModuleGL.cpp). A storage
+// buffer in the layout that is *not* visible to the stage being
+// compiled should not be given an entry in Tint's
+// bindpoint_to_size_index nor in the remapper_data. Otherwise,
+// ArrayLengthFromUniform loads the wrong UBO slot for arrayLength(),
+// defeating Robustness clamping.
+class OpArrayLengthVisibilityCollisionTest : public DawnTest {
+ protected:
+ void GetRequiredLimits(const dawn::utils::ComboLimits& supported,
+ dawn::utils::ComboLimits& required) override {
+ supported.UnlinkedCopyTo(&required);
+ }
+};
+
+TEST_P(OpArrayLengthVisibilityCollisionTest, ComputeWithFragmentOnlyBufferInLayout) {
+ DAWN_TEST_UNSUPPORTED_IF(GetSupportedLimits().maxStorageBuffersInFragmentStage < 1);
+
+ // Buffer A: large, bound at full size to a FRAGMENT-only storage slot.
+ constexpr uint32_t kBufASize = 1u << 20; // 1 MiB → arrayLength<u32> = 262144
+ wgpu::BufferDescriptor descA;
+ descA.size = kBufASize;
+ descA.usage = wgpu::BufferUsage::Storage;
+ wgpu::Buffer bufA = device.CreateBuffer(&descA);
+
+ // Buffer B: large allocation, but only a 64-byte sub-range will be bound to
+ // the COMPUTE-visible storage slot. With the collision the shader receives
+ // A's bound size as B's arrayLength, so the Robustness clamp on B[idx]
+ // permits writes far past the 64-byte bound range.
+ constexpr uint32_t kBufBSize = 16384; // 16 KiB underlying allocation
+ constexpr uint32_t kBufBBound = 64; // 64 bytes bound → arrayLength = 16
+ constexpr uint32_t kOobIndex = 1000; // Target B[1000] (byte 4000) – OOB
+ constexpr uint32_t kOobMarker = 0xDEAD4A11u;
+ std::vector<uint32_t> initialData(kBufBSize / 4, 0u);
+ wgpu::Buffer bufB = utils::CreateBufferFromData(
+ device, initialData.data(), initialData.size() * sizeof(uint32_t),
+ wgpu::BufferUsage::Storage | wgpu::BufferUsage::CopySrc | wgpu::BufferUsage::CopyDst);
+
+ // PipelineLayoutGL assigns ssboIndex by iterating groups in order, so:
+ // group 0 (A, FRAGMENT-only) → ssboIndex 0
+ // group 1 (B, COMPUTE) → ssboIndex 1 → glIndex_B = 1
+ // Choose A's WGSL @binding == glIndex_B (= 1) so that A's pre-remap key
+ // {0,1} equals B's post-remap key {0, glIndex_B}.
+ wgpu::BindGroupLayout bglA = utils::MakeBindGroupLayout(
+ device, {{1, wgpu::ShaderStage::Fragment, wgpu::BufferBindingType::ReadOnlyStorage}});
+ wgpu::BindGroupLayout bglB = utils::MakeBindGroupLayout(
+ device, {{0, wgpu::ShaderStage::Compute, wgpu::BufferBindingType::Storage}});
+
+ std::string shaderSource = R"(
+ @group(1) @binding(0) var<storage, read_write> B : array<u32>;
+ @compute @workgroup_size(1) fn main() {
+ B[0] = arrayLength(&B);
+ // Robustness wraps this as B[min(idx, arrayLength(&B)-1)].
+ // With the bug arrayLength(&B) is huge, so the write lands at
+ // index 1000 – past the 64-byte bound range.
+ B[)" + std::to_string(kOobIndex) +
+ R"(u] = )" + std::to_string(kOobMarker) + R"(u;
+ })";
+
+ wgpu::ComputePipelineDescriptor pipelineDesc;
+ pipelineDesc.layout = utils::MakePipelineLayout(device, {bglA, bglB});
+ pipelineDesc.compute.module = utils::CreateShaderModule(device, shaderSource);
+ pipelineDesc.compute.entryPoint = "main";
+ wgpu::ComputePipeline pipeline = device.CreateComputePipeline(&pipelineDesc);
+
+ wgpu::BindGroup bgA = utils::MakeBindGroup(device, bglA, {{1, bufA, 0, kBufASize}});
+ wgpu::BindGroup bgB = utils::MakeBindGroup(device, bglB, {{0, bufB, 0, kBufBBound}});
+
+ wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+ wgpu::ComputePassEncoder pass = encoder.BeginComputePass();
+ pass.SetPipeline(pipeline);
+ pass.SetBindGroup(0, bgA);
+ pass.SetBindGroup(1, bgB);
+ pass.DispatchWorkgroups(1);
+ pass.End();
+ wgpu::CommandBuffer commands = encoder.Finish();
+ queue.Submit(1, &commands);
+
+ EXPECT_BUFFER_U32_EQ(kBufBBound / 4, bufB, 0);
+ EXPECT_BUFFER_U32_EQ(0u, bufB, kOobIndex * sizeof(uint32_t));
+}
+
+DAWN_INSTANTIATE_TEST(OpArrayLengthVisibilityCollisionTest,
+ OpenGLESBackend(),
+ OpenGLESBackend({"gl_use_array_length_from_uniform"}),
+ VulkanBackend());
+
enum class TieredLimits {
No,
Yes,
Regression Test / PoC
diff --git a/src/dawn/tests/end2end/OpArrayLengthTests.cpp b/src/dawn/tests/end2end/OpArrayLengthTests.cpp
index ea36ca3..a96a58d 100644
--- a/src/dawn/tests/end2end/OpArrayLengthTests.cpp
+++ b/src/dawn/tests/end2end/OpArrayLengthTests.cpp
@@ -303,6 +303,93 @@
VulkanBackend(),
WebGPUBackend());
+// Regression test for stage-visibility filtering in
+// GenerateArrayLengthFromuniformData (ShaderModuleGL.cpp). A storage
+// buffer in the layout that is *not* visible to the stage being
+// compiled should not be given an entry in Tint's
+// bindpoint_to_size_index nor in the remapper_data. Otherwise,
+// ArrayLengthFromUniform loads the wrong UBO slot for arrayLength(),
+// defeating Robustness clamping.
+class OpArrayLengthVisibilityCollisionTest : public DawnTest {
+ protected:
+ void GetRequiredLimits(const dawn::utils::ComboLimits& supported,
+ dawn::utils::ComboLimits& required) override {
+ supported.UnlinkedCopyTo(&required);
+ }
+};
+
+TEST_P(OpArrayLengthVisibilityCollisionTest, ComputeWithFragmentOnlyBufferInLayout) {
+ DAWN_TEST_UNSUPPORTED_IF(GetSupportedLimits().maxStorageBuffersInFragmentStage < 1);
+
+ // Buffer A: large, bound at full size to a FRAGMENT-only storage slot.
+ constexpr uint32_t kBufASize = 1u << 20; // 1 MiB → arrayLength<u32> = 262144
+ wgpu::BufferDescriptor descA;
+ descA.size = kBufASize;
+ descA.usage = wgpu::BufferUsage::Storage;
+ wgpu::Buffer bufA = device.CreateBuffer(&descA);
+
+ // Buffer B: large allocation, but only a 64-byte sub-range will be bound to
+ // the COMPUTE-visible storage slot. With the collision the shader receives
+ // A's bound size as B's arrayLength, so the Robustness clamp on B[idx]
+ // permits writes far past the 64-byte bound range.
+ constexpr uint32_t kBufBSize = 16384; // 16 KiB underlying allocation
+ constexpr uint32_t kBufBBound = 64; // 64 bytes bound → arrayLength = 16
+ constexpr uint32_t kOobIndex = 1000; // Target B[1000] (byte 4000) – OOB
+ constexpr uint32_t kOobMarker = 0xDEAD4A11u;
+ std::vector<uint32_t> initialData(kBufBSize / 4, 0u);
+ wgpu::Buffer bufB = utils::CreateBufferFromData(
+ device, initialData.data(), initialData.size() * sizeof(uint32_t),
+ wgpu::BufferUsage::Storage | wgpu::BufferUsage::CopySrc | wgpu::BufferUsage::CopyDst);
+
+ // PipelineLayoutGL assigns ssboIndex by iterating groups in order, so:
+ // group 0 (A, FRAGMENT-only) → ssboIndex 0
+ // group 1 (B, COMPUTE) → ssboIndex 1 → glIndex_B = 1
+ // Choose A's WGSL @binding == glIndex_B (= 1) so that A's pre-remap key
+ // {0,1} equals B's post-remap key {0, glIndex_B}.
+ wgpu::BindGroupLayout bglA = utils::MakeBindGroupLayout(
+ device, {{1, wgpu::ShaderStage::Fragment, wgpu::BufferBindingType::ReadOnlyStorage}});
+ wgpu::BindGroupLayout bglB = utils::MakeBindGroupLayout(
+ device, {{0, wgpu::ShaderStage::Compute, wgpu::BufferBindingType::Storage}});
+
+ std::string shaderSource = R"(
+ @group(1) @binding(0) var<storage, read_write> B : array<u32>;
+ @compute @workgroup_size(1) fn main() {
+ B[0] = arrayLength(&B);
+ // Robustness wraps this as B[min(idx, arrayLength(&B)-1)].
+ // With the bug arrayLength(&B) is huge, so the write lands at
+ // index 1000 – past the 64-byte bound range.
+ B[)" + std::to_string(kOobIndex) +
+ R"(u] = )" + std::to_string(kOobMarker) + R"(u;
+ })";
+
+ wgpu::ComputePipelineDescriptor pipelineDesc;
+ pipelineDesc.layout = utils::MakePipelineLayout(device, {bglA, bglB});
+ pipelineDesc.compute.module = utils::CreateShaderModule(device, shaderSource);
+ pipelineDesc.compute.entryPoint = "main";
+ wgpu::ComputePipeline pipeline = device.CreateComputePipeline(&pipelineDesc);
+
+ wgpu::BindGroup bgA = utils::MakeBindGroup(device, bglA, {{1, bufA, 0, kBufASize}});
+ wgpu::BindGroup bgB = utils::MakeBindGroup(device, bglB, {{0, bufB, 0, kBufBBound}});
+
+ wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+ wgpu::ComputePassEncoder pass = encoder.BeginComputePass();
+ pass.SetPipeline(pipeline);
+ pass.SetBindGroup(0, bgA);
+ pass.SetBindGroup(1, bgB);
+ pass.DispatchWorkgroups(1);
+ pass.End();
+ wgpu::CommandBuffer commands = encoder.Finish();
+ queue.Submit(1, &commands);
+
+ EXPECT_BUFFER_U32_EQ(kBufBBound / 4, bufB, 0);
+ EXPECT_BUFFER_U32_EQ(0u, bufB, kOobIndex * sizeof(uint32_t));
+}
+
+DAWN_INSTANTIATE_TEST(OpArrayLengthVisibilityCollisionTest,
+ OpenGLESBackend(),
+ OpenGLESBackend({"gl_use_array_length_from_uniform"}),
+ VulkanBackend());
+
enum class TieredLimits {
No,
Yes,
Original Bug Report
Bypass of Dawn's SSBO robustness checks via stage-filtering mismatch in OpenGL 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: A potential logic flaw in Dawn’s OpenGL/GLES backend stage-filtering can cause a mapping collision during Tint’s compilation phase. This mismatch allows an inactive shader storage buffer’s size lookup configuration to overwrite an active buffer’s configuration, potentially causing robustness checks to use an incorrect (larger) buffer size. An attacker could exploit this behavior to perform out-of-bounds memory reads and writes on storage buffers.
Affected files:
third_party/dawn/src/dawn/native/opengl/ShaderModuleGL.cppthird_party/dawn/src/tint/lang/glsl/writer/raise/raise.cc
Estimated timestamp from git blame: 2025-04-10
Description
There is a potential stage-filtering inconsistency in Dawn’s OpenGL backend during the translation of WGSL to GLSL. When generating the layout metadata for shader compilation, Dawn constructs remapping tables and uniform-based array length tables. However, the logic that populates these tables handles stage-visibility differently:
-
Active Binding Remapping:
GenerateBindingRemapping(defined inthird_party/dawn/src/dawn/native/TintUtils.h) correctly checks stage-visibility usingStageBit(stage). Non-stage-visible bindings are skipped:for (const auto& [bindingNumber, apiBindingIndex] : bgl->GetBindingMap()) { if (!(bgl->GetAPIBindingInfo(apiBindingIndex).visibility & StageBit(stage))) { continue; } -
Array Length Uniform Options:
GenerateArrayLengthFromuniformData(defined inthird_party/dawn/src/dawn/native/opengl/ShaderModuleGL.cpp) does not check stage-visibility. It populatesbindpoint_to_size_indexfor all storage buffers declared in the pipeline layout, regardless of whether they are active or visible in the current stage:for (BindingIndex binding : bgl->GetBufferIndices()) { const BindingInfo& bindingInfo = bgl->GetBindingInfo(binding); switch (std::get<BufferBindingInfo>(bindingInfo.bindingLayout).type) { case wgpu::BufferBindingType::Storage: // ... tint::BindingPoint srcBindingPoint = {uint32_t(group), uint32_t(bindingInfo.binding)}; options.bindpoint_to_size_index.emplace(srcBindingPoint, uint32_t(ssboIndex));
Potential Exploit Mechanism
An attacker could potentially configure a pipeline layout containing:
- Buffer A (inactive in the compute stage) at Bind Group
0, BindingN(pre-remap:{0, N}). - Buffer B (active in the compute stage) at Bind Group
1, BindingM(pre-remap:{1, M}).
During compilation of a compute shader that only utilizes Buffer B:
-
Remapping Stage:
GenerateBindingRemappingfilters out Buffer A. It remaps Buffer B from{1, M}to a flat 1D binding point (e.g.,{0, N}). Consequently, the remapper data used by Tint (remapper_data) maps{1, M} -> {0, N}. It contains no entries for Buffer A’s original binding point{0, N}. -
Array Length Options Stage:
GenerateArrayLengthFromuniformDatapopulates options for both buffers:{1, M} -> index_B(for Buffer B){0, N} -> index_A(for Buffer A)
-
Map Collision in Tint: During Tint’s raise phase in
third_party/dawn/src/tint/lang/glsl/writer/raise/raise.cc, Tint resolves the pre-remapped keys ofbindpoint_to_size_indexto their post-remapped targets to build a finalsize_indicesmap:std::unordered_map<BindingPoint, uint32_t> size_indices; for (auto& pair : options.array_length_from_uniform.bindpoint_to_size_index) { auto& bp = pair.first; auto& index = pair.second; where = remapper_data.find(bp); if (where != remapper_data.end()) { size_indices[where->second] = index; } else { size_indices[bp] = index; } }- When processing Buffer B (
{1, M}), Tint resolves it viaremapper_datato{0, N}and setssize_indices[{0, N}] = index_B. - When processing Buffer A (
{0, N}), Tint does not find it inremapper_dataand falls back to theelseblock, settingsize_indices[{0, N}] = index_A. - This causes a key collision inside the
std::unordered_map. Buffer A’s entry overwrites Buffer B’s size index configuration, setting it toindex_Ainstead ofindex_B.
- When processing Buffer B (
-
Robustness Bypass: When Tint substitutes
.length()calls or injects robustness-clamping bound checks for Buffer B (binding point{0, N}), it retrieves the size fromindex_A(the index corresponding to Buffer A). If the attacker binds a very large buffer to Buffer A and a small buffer to Buffer B, the runtime clamping checks for Buffer B will clamp against the size of Buffer A, allowing out-of-bounds reads and writes on the memory backing Buffer B.
Note: These steps are based on static code analysis; our tooling does not currently have the environment or capability to run a live dynamic Proof-of-Concept to verify execution.
Impact
This flaw allows a shader to bypass WebGPU/Tint’s software robustness safety guarantees. On GLES backends (such as older Android devices with PowerVR or Nvidia GPUs), the GPU process is typically unsandboxed, potentially allowing GPU memory corruption or sandbox escape.
Suggested Fix
Modify GenerateArrayLengthFromuniformData in third_party/dawn/src/dawn/native/opengl/ShaderModuleGL.cpp to filter out storage buffers that are not visible to the shader stage currently being compiled, matching the visibility filter used in GenerateBindingRemapping:
for (BindingIndex binding : bgl->GetBufferIndices()) {
const BindingInfo& bindingInfo = bgl->GetBindingInfo(binding);
if (!(bindingInfo.visibility & StageBit(stage))) {
continue;
}
// ...
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.