Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Dawn
DescriptionOut of bounds read in Dawn
ComponentDawn
Bug ClassOOB
Tracker497975608
Fix commit53ab80b61447 (dawn) +3780/-2840
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
for
src/tint/lang/wgsl/inspector/inspector.cc
modified
if
src/tint/lang/wgsl/inspector/inspector.cc
modified
TEST_F
src/tint/lang/wgsl/inspector/inspector_test.cc
modified

Files Changed

  • src/tint/lang/core/ir/transform/robustness.cc
  • src/tint/lang/core/ir/transform/robustness_test.cc
  • src/tint/lang/wgsl/inspector/inspector.cc
  • src/tint/lang/wgsl/inspector/inspector_test.cc
From 53ab80b614475f245e7dc6dbf882ce7b24247c98 Mon Sep 17 00:00:00 2001
From: James Price <[email protected]>
Date: Thu, 02 Apr 2026 16:22:10 -0700
Subject: [PATCH] [tint] Clamp sample index for textureLoad on multisampled textures

An out-of-bounds sample index returns an undefined value in some APIs,
which could potentially produce uninitialized values.

Update the inspector to reflect the new usage of textureNumSamples to
Dawn for the GLSL backend.

Fixed: 497975608
Change-Id: I6f3dfc073d317516e6cdc94918182e1fd7a6cab9
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/300995
Reviewed-by: dan sinclair <[email protected]>
Commit-Queue: James Price <[email protected]>
---

diff --git a/src/tint/lang/core/ir/transform/robustness.cc b/src/tint/lang/core/ir/transform/robustness.cc
index 82cc820..545fe2d 100644
--- a/src/tint/lang/core/ir/transform/robustness.cc
+++ b/src/tint/lang/core/ir/transform/robustness.cc
@@ -377,6 +377,14 @@
                              b.Min(CastToU32(args[idx]), limit)->Result());
         };
 
+        // Helper for clamping the sample index.
+        auto clamp_sample_index = [&](uint32_t idx) {
+            auto* num_samples = b.Call(ty.u32(), core::BuiltinFn::kTextureNumSamples, args[0]);
+            auto* limit = b.Subtract(num_samples, 1_u);
+            call->SetOperand(CoreBuiltinCall::kArgsOperandOffset + idx,
+                             b.Min(CastToU32(args[idx]), limit)->Result());
+        };
+
         // Select which arguments to clamp based on the function overload.
         switch (call->Func()) {
             case core::BuiltinFn::kTextureDimensions: {
@@ -393,6 +401,9 @@
                 if (texture->IsAnyOf<type::SampledTexture, type::DepthTexture>()) {
                     clamp_level(next_arg++);
                 }
+                if (texture->IsAnyOf<type::MultisampledTexture, type::DepthMultisampledTexture>()) {
+                    clamp_sample_index(next_arg++);
+                }
                 clamp_coords(1u);  // Must run after clamp_level
                 break;
             }
diff --git a/src/tint/lang/core/ir/transform/robustness_test.cc b/src/tint/lang/core/ir/transform/robustness_test.cc
index 4af13d0..01f13ed 100644
--- a/src/tint/lang/core/ir/transform/robustness_test.cc
+++ b/src/tint/lang/core/ir/transform/robustness_test.cc
@@ -2339,22 +2339,29 @@
 %load_signed = func(%coords:vec2<i32>, %level:i32):vec4<f32> {
   $B2: {
     %5:texture_multisampled_2d<f32> = load %texture
-    %6:vec2<u32> = textureDimensions %5
-    %7:vec2<u32> = sub %6, vec2<u32>(1u)
-    %8:vec2<u32> = convert %coords
-    %9:vec2<u32> = min %8, %7
-    %10:vec4<f32> = textureLoad %5, %9, %level
-    ret %10
+    %6:u32 = textureNumSamples %5
+    %7:u32 = sub %6, 1u
+    %8:u32 = convert %level
+    %9:u32 = min %8, %7
+    %10:vec2<u32> = textureDimensions %5
+    %11:vec2<u32> = sub %10, vec2<u32>(1u)
+    %12:vec2<u32> = convert %coords
+    %13:vec2<u32> = min %12, %11
+    %14:vec4<f32> = textureLoad %5, %13, %9
+    ret %14
   }
 }
 %load_unsigned = func(%coords_1:vec2<u32>, %level_1:u32):vec4<f32> {  # %coords_1: 'coords', %level_1: 'level'
   $B3: {
-    %14:texture_multisampled_2d<f32> = load %texture
-    %15:vec2<u32> = textureDimensions %14
-    %16:vec2<u32> = sub %15, vec2<u32>(1u)
-    %17:vec2<u32> = min %coords_1, %16
-    %18:vec4<f32> = textureLoad %14, %17, %level_1
-    ret %18
+    %18:texture_multisampled_2d<f32> = load %texture
+    %19:u32 = textureNumSamples %18
+    %20:u32 = sub %19, 1u
+    %21:u32 = min %level_1, %20
+    %22:vec2<u32> = textureDimensions %18
+    %23:vec2<u32> = sub %22, vec2<u32>(1u)
+    %24:vec2<u32> = min %coords_1, %23
+    %25:vec4<f32> = textureLoad %18, %24, %21
+    ret %25
   }
 }
 )";
@@ -2626,22 +2633,29 @@
 %load_signed = func(%coords:vec2<i32>, %index:i32):f32 {
   $B2: {
     %5:texture_depth_multisampled_2d = load %texture
-    %6:vec2<u32> = textureDimensions %5
-    %7:vec2<u32> = sub %6, vec2<u32>(1u)
-    %8:vec2<u32> = convert %coords
-    %9:vec2<u32> = min %8, %7
-    %10:f32 = textureLoad %5, %9, %index
-    ret %10
+    %6:u32 = textureNumSamples %5
+    %7:u32 = sub %6, 1u
+    %8:u32 = convert %index
+    %9:u32 = min %8, %7
+    %10:vec2<u32> = textureDimensions %5
+    %11:vec2<u32> = sub %10, vec2<u32>(1u)
+    %12:vec2<u32> = convert %coords
+    %13:vec2<u32> = min %12, %11
+    %14:f32 = textureLoad %5, %13, %9
+    ret %14
   }
 }
 %load_unsigned = func(%coords_1:vec2<u32>, %index_1:u32):f32 {  # %coords_1: 'coords', %index_1: 'index'
   $B3: {
-    %14:texture_depth_multisampled_2d = load %texture
-    %15:vec2<u32> = textureDimensions %14
-    %16:vec2<u32> = sub %15, vec2<u32>(1u)
-    %17:vec2<u32> = min %coords_1, %16
-    %18:f32 = textureLoad %14, %17, %index_1
-    ret %18
+    %18:texture_depth_multisampled_2d = load %texture
+    %19:u32 = textureNumSamples %18
+    %20:u32 = sub %19, 1u
+    %21:u32 = min %index_1, %20
+    %22:vec2<u32> = textureDimensions %18
+    %23:vec2<u32> = sub %22, vec2<u32>(1u)
+    %24:vec2<u32> = min %coords_1, %23
+    %25:f32 = textureLoad %18, %24, %21
+    ret %25
   }
 }
 )";
diff --git a/src/tint/lang/wgsl/inspector/inspector.cc b/src/tint/lang/wgsl/inspector/inspector.cc
index b8b0480..f9ecbad 100644
--- a/src/tint/lang/wgsl/inspector/inspector.cc
+++ b/src/tint/lang/wgsl/inspector/inspector.cc
@@ -639,6 +639,7 @@
         }
 
         bool uses_num_levels = false;
+        bool uses_num_samples = false;
         switch (builtin->Fn()) {
             case wgsl::BuiltinFn::kTextureNumLevels:
                 uses_num_levels = true;
@@ -656,6 +657,8 @@
                 uses_num_levels = !texture_type->IsAnyOf<core::type::MultisampledTexture,
                                                          core::type::DepthMultisampledTexture,
                                                          core::type::ExternalTexture>();
+                uses_num_samples = texture_type->IsAnyOf<core::type::MultisampledTexture,
+                                                         core::type::DepthMultisampledTexture>();
                 metadata.has_texture_load_with_depth_texture |=
                     texture_type
                         ->IsAnyOf<core::type::DepthTexture, core::type::DepthMultisampledTexture>();
@@ -670,10 +673,7 @@
                 break;
 
             case wgsl::BuiltinFn::kTextureNumSamples:
-                for (const auto* texture : textures) {
-                    auto texture_binding_point = texture->Attributes().binding_point.value();
-                    metadata.textures_with_num_samples.insert(texture_binding_point);
-                }
+                uses_num_samples = true;
                 break;
 
             default:
@@ -686,6 +686,12 @@
                 metadata.textures_with_num_levels.insert(texture_binding_point);
             }
         }
+        if (uses_num_samples) {
+            for (const auto* texture : textures) {
+                auto texture_binding_point = texture->Attributes().binding_point.value();
+                metadata.textures_with_num_samples.insert(texture_binding_point);
+            }
+        }
     };
 
     // Iterate the call graph in reverse topological order such that function callers come before
diff --git a/src/tint/lang/wgsl/inspector/inspector_test.cc b/src/tint/lang/wgsl/inspector/inspector_test.cc
index 09215dd..6c97476 100644
--- a/src/tint/lang/wgsl/inspector/inspector_test.cc
+++ b/src/tint/lang/wgsl/inspector/inspector_test.cc
@@ -4285,7 +4285,11 @@
     Inspector& inspector = Initialize(shader);
     auto info = inspector.GetTextureQueries("main");
 
-    ASSERT_EQ(0u, info.size());
+    ASSERT_EQ(1u, info.size());
+
+    EXPECT_EQ(Inspector::TextureQueryType::kTextureNumSamples, info[0].type);
+    EXPECT_EQ(2u, info[0].group);
+    EXPECT_EQ(3u, info[0].binding);
 }
 
 TEST_F(InspectorTextureTest, TextureLoadMultipleInEP) {
@@ -4306,7 +4310,7 @@
     Inspector& inspector = Initialize(shader);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tint/lang/core/ir/transform/robustness_test.cc b/src/tint/lang/core/ir/transform/robustness_test.cc
index 4af13d0..01f13ed 100644
--- a/src/tint/lang/core/ir/transform/robustness_test.cc
+++ b/src/tint/lang/core/ir/transform/robustness_test.cc
@@ -2339,22 +2339,29 @@
 %load_signed = func(%coords:vec2<i32>, %level:i32):vec4<f32> {
   $B2: {
     %5:texture_multisampled_2d<f32> = load %texture
-    %6:vec2<u32> = textureDimensions %5
-    %7:vec2<u32> = sub %6, vec2<u32>(1u)
-    %8:vec2<u32> = convert %coords
-    %9:vec2<u32> = min %8, %7
-    %10:vec4<f32> = textureLoad %5, %9, %level
-    ret %10
+    %6:u32 = textureNumSamples %5
+    %7:u32 = sub %6, 1u
+    %8:u32 = convert %level
+    %9:u32 = min %8, %7
+    %10:vec2<u32> = textureDimensions %5
+    %11:vec2<u32> = sub %10, vec2<u32>(1u)
+    %12:vec2<u32> = convert %coords
+    %13:vec2<u32> = min %12, %11
+    %14:vec4<f32> = textureLoad %5, %13, %9
+    ret %14
   }
 }
 %load_unsigned = func(%coords_1:vec2<u32>, %level_1:u32):vec4<f32> {  # %coords_1: 'coords', %level_1: 'level'
   $B3: {
-    %14:texture_multisampled_2d<f32> = load %texture
-    %15:vec2<u32> = textureDimensions %14
-    %16:vec2<u32> = sub %15, vec2<u32>(1u)
-    %17:vec2<u32> = min %coords_1, %16
-    %18:vec4<f32> = textureLoad %14, %17, %level_1
-    ret %18
+    %18:texture_multisampled_2d<f32> = load %texture
+    %19:u32 = textureNumSamples %18
+    %20:u32 = sub %19, 1u
+    %21:u32 = min %level_1, %20
+    %22:vec2<u32> = textureDimensions %18
+    %23:vec2<u32> = sub %22, vec2<u32>(1u)
+    %24:vec2<u32> = min %coords_1, %23
+    %25:vec4<f32> = textureLoad %18, %24, %21
+    ret %25
   }
 }
 )";
@@ -2626,22 +2633,29 @@
 %load_signed = func(%coords:vec2<i32>, %index:i32):f32 {
   $B2: {
     %5:texture_depth_multisampled_2d = load %texture
-    %6:vec2<u32> = textureDimensions %5
-    %7:vec2<u32> = sub %6, vec2<u32>(1u)
-    %8:vec2<u32> = convert %coords
-    %9:vec2<u32> = min %8, %7
-    %10:f32 = textureLoad %5, %9, %index
-    ret %10
+    %6:u32 = textureNumSamples %5
+    %7:u32 = sub %6, 1u
+    %8:u32 = convert %index
+    %9:u32 = min %8, %7
+    %10:vec2<u32> = textureDimensions %5
+    %11:vec2<u32> = sub %10, vec2<u32>(1u)
+    %12:vec2<u32> = convert %coords
+    %13:vec2<u32> = min %12, %11
+    %14:f32 = textureLoad %5, %13, %9
+    ret %14
   }
 }
 %load_unsigned = func(%coords_1:vec2<u32>, %index_1:u32):f32 {  # %coords_1: 'coords', %index_1: 'index'
   $B3: {
-    %14:texture_depth_multisampled_2d = load %texture
-    %15:vec2<u32> = textureDimensions %14
-    %16:vec2<u32> = sub %15, vec2<u32>(1u)
-    %17:vec2<u32> = min %coords_1, %16
-    %18:f32 = textureLoad %14, %17, %index_1
-    ret %18
+    %18:texture_depth_multisampled_2d = load %texture
+    %19:u32 = textureNumSamples %18
+    %20:u32 = sub %19, 1u
+    %21:u32 = min %index_1, %20
+    %22:vec2<u32> = textureDimensions %18
+    %23:vec2<u32> = sub %22, vec2<u32>(1u)
+    %24:vec2<u32> = min %coords_1, %23
+    %25:f32 = textureLoad %18, %24, %21
+    ret %25
   }
 }
 )";
diff --git a/src/tint/lang/wgsl/inspector/inspector_test.cc b/src/tint/lang/wgsl/inspector/inspector_test.cc
index 09215dd..6c97476 100644
--- a/src/tint/lang/wgsl/inspector/inspector_test.cc
+++ b/src/tint/lang/wgsl/inspector/inspector_test.cc
@@ -4285,7 +4285,11 @@
     Inspector& inspector = Initialize(shader);
     auto info = inspector.GetTextureQueries("main");
 
-    ASSERT_EQ(0u, info.size());
+    ASSERT_EQ(1u, info.size());
+
+    EXPECT_EQ(Inspector::TextureQueryType::kTextureNumSamples, info[0].type);
+    EXPECT_EQ(2u, info[0].group);
+    EXPECT_EQ(3u, info[0].binding);
 }
 
 TEST_F(InspectorTextureTest, TextureLoadMultipleInEP) {
@@ -4306,7 +4310,7 @@
     Inspector& inspector = Initialize(shader);
     auto info = inspector.GetTextureQueries("main");
 
-    ASSERT_EQ(2u, info.size());
+    ASSERT_EQ(3u, info.size());
 
     Inspector::LevelSampleInfo info1 = {
         /*type */ Inspector::TextureQueryType::kTextureNumLevels,
@@ -4318,7 +4322,12 @@
         /*group*/ 2,
         /*binding*/ 3,
     };
-    EXPECT_THAT(info, testing::UnorderedElementsAre(info1, info2));
+    Inspector::LevelSampleInfo info3 = {
+        /*type */ Inspector::TextureQueryType::kTextureNumSamples,
+        /*group*/ 1,
+        /*binding*/ 4,
+    };
+    EXPECT_THAT(info, testing::UnorderedElementsAre(info1, info2, info3));
 }
 
 TEST_F(InspectorTextureTest, TextureInSubfunction) {
Loading diff…

Original Bug Report

reported by [email protected]

OOB Read via Unclamped sample_index in Multisampled textureLoad

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: Tint’s Robustness transform fails to clamp the sample_index argument for textureLoad operations on multisampled textures. This allows an attacker to pass an out-of-bounds sample index to the Metal and OpenGL backends, potentially leading to an out-of-bounds read of adjacent or stale GPU memory and a cross-origin information leak.

Affected files:

  • third_party/dawn/src/tint/lang/core/ir/transform/robustness.cc

Estimated timestamp from git blame: 2024-11-12

Summary

Tint’s IR Robustness transform (third_party/dawn/src/tint/lang/core/ir/transform/robustness.cc) incorrectly skips clamping the sample_index argument for textureLoad operations on multisampled textures (specifically texture_multisampled_2d<T> and texture_depth_multisampled_2d). This omission results in a potential out-of-bounds (OOB) read vulnerability on the GPU when using WebGPU on platforms where Tint is the primary robustness mechanism, such as macOS/iOS (Metal) and OpenGL.

Technical Details

In third_party/dawn/src/tint/lang/core/ir/transform/robustness.cc, the function ClampTextureCallArgs contains logic for handling textureLoad built-ins. For multisampled textures, the sample_index is the third argument (args[2]). The current clamping logic is as follows:

case core::BuiltinFn::kTextureLoad: {
    uint32_t next_arg = 2u;
    if (type::IsTextureArray(texture->Dim())) {
        clamp_array_index(next_arg++);
    }
    if (texture->IsAnyOf<type::SampledTexture, type::DepthTexture>()) {
        clamp_level(next_arg++);
    }
    clamp_coords(1u);  // Must run after clamp_level
    break;
}

The texture->IsAnyOf<type::SampledTexture, type::DepthTexture>() check incorrectly excludes multisampled texture types. In the Tint type system, MultisampledTexture and DepthMultisampledTexture are final classes that derive directly from the base Texture class and are not instances of SampledTexture or DepthTexture.

Consequently, for multisampled textures, the transform evaluates this condition as false and entirely skips clamping the argument at next_arg (index 2).

Since no clamp_sample_index helper is implemented in the transform and kTextureNumSamples is never queried to determine the valid range, the sample_index value remains completely unmodified in the generated Tint IR. This behavior is explicitly reflected in Tint’s golden tests (e.g., TextureLoad_Multisampled2D in robustness_test.cc), which show coordinates being safely clamped while the sample index remains raw.

Impact

When lowered to Metal Shading Language (MSL) or GLSL, the unclamped sample_index is passed directly to the underlying driver functions (e.g., texture.read(coords, sample_index) in MSL or texelFetch() in GLSL). Both the MSL and GLSL specifications define out-of-bounds sample indices as undefined behavior.

On many GPUs, particularly those with Tile Based Deferred Rendering (TBDR) architectures like Apple Silicon, an out-of-bounds sample read causes the hardware sampling unit to calculate an offset far outside the memory region allocated for that pixel or tile. This returns stale data from adjacent tile memory or other GPU device memory allocations. This allows a malicious WebGPU-enabled website to perform an information leak of highly sensitive, cross-origin texture data or other GPU-resident information without triggering crashes or driver errors.

Potential Reproduction Steps

(Note: These are potential steps as our tooling agent cannot execute code to verify the exploit end-to-end.)

  1. On a system using Metal (macOS) or OpenGL, open a browser with WebGPU enabled.
  2. Create a multisampled texture (texture_multisampled_2d<f32>).
  3. Create a compute shader that calls textureLoad(tex, vec2<i32>(0,0), sample_index). Source the sample_index from an attacker-controlled storage buffer.
  4. Populate the storage buffer with a massively out-of-bounds value (e.g., 0x7FFFFFFF).
  5. Execute the compute pass and write the result of the textureLoad call to an output storage buffer.
  6. Map the output buffer and read it back to the CPU via JavaScript. Observe that the returned values contain undefined data from adjacent GPU memory, violating the WebGPU specification’s out-of-bounds safety guarantees.

Suggested Fix

Update ClampTextureCallArgs in third_party/dawn/src/tint/lang/core/ir/transform/robustness.cc to explicitly handle multisampled textures.

  1. Implement a clamp_sample_index helper lambda that queries core::BuiltinFn::kTextureNumSamples to determine the maximum valid sample index for the given texture (num_samples - 1).
  2. Add a check if (texture->IsAnyOf<type::MultisampledTexture, type::DepthMultisampledTexture>()) within the kTextureLoad case block to call this new clamp_sample_index helper on the appropriate argument index (next_arg).

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker