CVE-2026-5869
Overview
Background
- WebNN `conv2d` groups
- the Web Neural Network convolution operator supports grouped convolution, where the
groupsattribute partitions input and output channels into independent convolution groups. - `ValidateConv2d`
- the shared graph-validation routine (run in the WebNN service) that checks a
conv2doperator’s operand shapes and attributes before the graph is built and executed on a backend. - Grouped-conv channel invariant
- for grouped convolution, both the input-channel count and the output-channel count must be exactly divisible by
groups, so each group owns an equal, whole number of channels. - `base::unexpected`
- the error-return mechanism; returning it rejects the graph with a labeled validation error instead of proceeding.
Root Cause Analysis
ValidateConv2d already rejected graphs where groups did not evenly divide the input channels, but it had no equivalent check that groups evenly divides the output channels. With a filter/output shape such as output_channels = 7 and groups = 2, validation passed even though 7 is not a multiple of 2, so the per-group output-channel count (output_channels / groups) was computed by truncating division and did not account for the full declared output extent. Downstream size and offset calculations in the backend kernels then rely on this malformed, inconsistent channel partitioning, so the output buffer that is allocated and the extent the kernel iterates over disagree, leading to a heap buffer overflow (inferred for the exact write).
The fix adds if (output_channels % attributes.groups != 0) immediately after the input-channel divisibility check and returns base::unexpected(ErrorWithLabel(label, "The groups must evenly divide the output channels.")), rejecting the malformed graph before any sizes are calculated (the new check runs before the ASSIGN_OR_RETURN output-size computation that follows it).
groups but omitted the same requirement on output channels, letting a non-divisible output-channel count through; adding the output_channels % groups != 0 rejection restores the grouped-convolution invariant.Attack Path
- Construct a malformed grouped conv
From a renderer, script builds a
conv2dviaMLGraphBuilderwithgroupsset so that the declared output channels are not a multiple ofgroups(e.g. output channels 7,groups2). - Pass validation
The graph reaches
ValidateConv2d, which previously only checked input-channel divisibility and accepted the non-divisible output-channel configuration. - Inconsistent group sizing Backend lowering/execution derives per-group output extents from the malformed channel count, so allocated and iterated sizes diverge.
- Heap overflow
Executing the convolution reads/writes outside the output allocation, a heap buffer overflow in the WebNN backend
(inferred); the added test (output_channels (7) % groups (2) != 0,expected = false) pins the rejection.
Impact Assessment
conv2d API, where the feature is enabled) can get a malformed grouped-convolution configuration past validation and provoke a heap buffer overflow in the WebNN service during graph execution. The vendor advisory rates this high severity and labels it a heap buffer overflow in WebML; it does not report in-the-wild exploitation. The exact out-of-bounds sink and any escalation beyond the overflow are not demonstrated by the patch (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifservices/webnn/public/cpp/graph_validation_utils.cc |
modified |
Files Changed
services/webnn/public/cpp/graph_validation_utils.ccservices/webnn/webnn_graph_impl_unittest.cc
Audit Directions
- Paired divisibility invariantsWhere a validator enforces a divisibility/consistency rule on one dimension (input channels), check that the symmetric rule on the related dimension (output channels) is also enforced; missing one side is a common gap.
- Grouped / blocked operatorsAudit other WebNN operators and backends that partition tensors by a
groups-like attribute (conv variants, grouped matmul) for complete divisibility validation of every partitioned axis. - Validation-before-sizing orderingConfirm all shape/attribute invariants are validated before any output-size or per-group arithmetic runs, so later computations never operate on an unvalidated configuration.
Patch
From c75f63de718803f929ada79ff96e9cb36d1acb2c Mon Sep 17 00:00:00 2001
From: Lynne Jiang <lyjiang@google.com>
Date: Fri, 20 Mar 2026 14:20:32 -0700
Subject: [PATCH] [webnn] Validate output channels are a multiple of groups in Conv2d.
Add a check in `ValidateConv2d` to ensure `output_channels % attributes.groups == 0`. This is a requirement for grouped convolutions. Add a unit test to cover this invalid case.
Bug: 493708165
Change-Id: Id83552a5fb2b95f84981f28ca7162331e17559cd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7687895
Commit-Queue: Lynne Jiang <lyjiang@google.com>
Reviewed-by: Phillis Tang <phillis@chromium.org>
Reviewed-by: Reilly Grant <reillyg@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1602846}
---
diff --git a/services/webnn/public/cpp/graph_validation_utils.cc b/services/webnn/public/cpp/graph_validation_utils.cc
index df74635..0b685a9 100644
--- a/services/webnn/public/cpp/graph_validation_utils.cc
+++ b/services/webnn/public/cpp/graph_validation_utils.cc
@@ -733,6 +733,10 @@
"The groups must evenly divide the input channels to filter input "
"channels."));
}
+ if (output_channels % attributes.groups != 0) {
+ return base::unexpected(ErrorWithLabel(
+ label, "The groups must evenly divide the output channels."));
+ }
// Validate and calculate output sizes.
ASSIGN_OR_RETURN(
diff --git a/services/webnn/webnn_graph_impl_unittest.cc b/services/webnn/webnn_graph_impl_unittest.cc
index faa0a36..3f8e930d 100644
--- a/services/webnn/webnn_graph_impl_unittest.cc
+++ b/services/webnn/webnn_graph_impl_unittest.cc
@@ -1286,6 +1286,20 @@
.Test(*this);
}
{
+ // Test invalid conv2d: output_channels is not a multiple of groups.
+ // output_channels (7) % groups (2) != 0.
+ Conv2dTester{.type = mojom::Conv2d::Kind::kDirect,
+ .input = {.type = OperandDataType::kFloat32,
+ .dimensions = {1, 4, 5, 5}},
+ .filter = {.type = OperandDataType::kFloat32,
+ .dimensions = {7, 2, 3, 3}},
+ .attributes = {.groups = 2},
+ .output = {.type = OperandDataType::kFloat32,
+ .dimensions = {1, 7, 3, 3}},
+ .expected = false}
+ .Test(*this);
+ }
+ {
// Test the invalid graph when the number of filter input channels
// doesn't match the result of input channels divided by groups
Conv2dTester{
Regression Test / PoC
diff --git a/services/webnn/webnn_graph_impl_unittest.cc b/services/webnn/webnn_graph_impl_unittest.cc
index faa0a36..3f8e930d 100644
--- a/services/webnn/webnn_graph_impl_unittest.cc
+++ b/services/webnn/webnn_graph_impl_unittest.cc
@@ -1286,6 +1286,20 @@
.Test(*this);
}
{
+ // Test invalid conv2d: output_channels is not a multiple of groups.
+ // output_channels (7) % groups (2) != 0.
+ Conv2dTester{.type = mojom::Conv2d::Kind::kDirect,
+ .input = {.type = OperandDataType::kFloat32,
+ .dimensions = {1, 4, 5, 5}},
+ .filter = {.type = OperandDataType::kFloat32,
+ .dimensions = {7, 2, 3, 3}},
+ .attributes = {.groups = 2},
+ .output = {.type = OperandDataType::kFloat32,
+ .dimensions = {1, 7, 3, 3}},
+ .expected = false}
+ .Test(*this);
+ }
+ {
// Test the invalid graph when the number of filter input channels
// doesn't match the result of input channels divided by groups
Conv2dTester{
Original Bug Report
Missing output channel divisibility check in grouped convolution leads to heap OOB read in the GPU process via WebNN
Missing output channel divisibility check in grouped convolution leads to heap OOB read in the GPU process via WebNN
Summary
The WebNN validation for conv2d with groups > 1 checks that input_channels % groups == 0 but omits the symmetric check that output_channels % groups == 0. When an attacker supplies a filter with an output channel count not divisible by the group count, the TFLite reference convolution kernel computes a truncated filters_per_group value that causes the inner loop to read input channels beyond the tensor boundary. Because the WebNN TFLite backend supplies input tensor data through SetCustomAllocationForTensor, the input buffer is a standalone heap allocation, and the out-of-bounds read is a direct heap overflow in the GPU process. Affected platforms: all platforms where the TFLite WebNN backend is active (Linux, macOS, Windows, ChromeOS).
Bisect
TFLite upstream — grouped conv reference kernel introduced
- Commit:
8ec0a3be4ccb1c13a32125ce9f17c3a919b36a68 - Date: 2022-03-16
- Author: Weiyi Wang <weiyiw@google.com>
- Subject: Support group conv2d in reference kernels
- Piper: PiperOrigin-RevId: 435188767
- URL: https://github.com/tensorflow/tensorflow/commit/8ec0a3be4ccb1c13a32125ce9f17c3a919b36a68
This commit introduced const int groups = input_depth / filter_input_depth; const int filters_per_group = output_depth / groups; in reference/conv.h without checking output_depth % groups == 0. Integer division truncation causes filters_per_group to be too small, so the last few output channels compute a group index of groups (out of range [0, groups-1]), leading to OOB reads on the input tensor.
Became web-reachable
- Commit:
ded4396ef94db6b715dda888751563a079f5497e - Date: 2024-03-12
- Author: Junwei Fu (junwei.fu@intel.com)
- Subject: webnn: Support Conv2d in TFLite converter
- Review: https://chromium-review.googlesource.com/c/chromium/src/+/5337479
This commit added SerializeConv2d to the WebNN TFLite graph builder. It used IsDepthwiseConv2d() to route depthwise cases but let all other grouped conv (groups > 1) fall through to TFLite’s CONV_2D operator without rejecting or validating output_channels % groups == 0.
Root Cause
When a WebNN conv2d operation specifies groups > 1, the validation in ValidateConv2dAndInferOutput enforces only two constraints on the groups parameter:
// services/webnn/public/cpp/graph_validation_utils.cc:724-735
if (attributes.groups == 0) {
return base::unexpected(
ErrorWithLabel(label, "The groups should be greater than 0."));
}
if (input_info.channels % attributes.groups != 0 ||
filter_input_channels != input_info.channels / attributes.groups) {
return base::unexpected(ErrorWithLabel(
label,
"The groups must evenly divide the input channels to filter input "
"channels."));
}
There is no check that output_channels % groups == 0. The TFLite conv::Prepare function similarly computes data->groups = input_channel / filter_input_channel without verifying the output side. When groups != 1, the TFLite evaluation path forces the reference kernel, which derives per-group assignments using truncated integer division:
// third_party/tflite/src/tensorflow/lite/kernels/internal/reference/conv.h:58-72
const int groups = input_depth / filter_input_depth;
const int filters_per_group = output_depth / groups;
...
for (int out_channel = 0; out_channel < output_depth; ++out_channel) {
auto group = out_channel / filters_per_group;
...
for (int in_channel = 0; in_channel < filter_input_depth; ++in_channel) {
float input_value =
input_data[Offset(input_shape, batch, in_y, in_x,
in_channel + group * filter_input_depth)];
When output_depth is not divisible by groups, filters_per_group is the floor of the true quotient. The remainder output channels produce a group index equal to groups itself rather than staying within the valid range [0, groups - 1]. The resulting channel offset in_channel + group * filter_input_depth then exceeds input_depth, causing a read past the end of the input tensor.
Consider a concrete construction: input shape [1, 1, 1, 100], filter shape [101, 1, 1, 50], and groups = 2. Validation passes because 100 % 2 == 0 and filter_input_channels == 50 == 100 / 2. The reference kernel then computes filters_per_group = 101 / 2 = 50. For out_channel = 100, group = 100 / 50 = 2, which is out of range. The inner loop reads at channel indices 100 through 149, which lie 200 bytes past the 400-byte input buffer.
The input tensor’s backing memory is allocated by BufferContent::BufferContent through base::AlignedAlloc and installed via interpreter_->SetCustomAllocationForTensor. This is a standalone heap allocation with ASAN redzones, so the out-of-bounds read is a genuine heap overflow rather than an intra-arena access.
The same truncated-division pattern is replicated in the quantized and hybrid Conv reference kernels in reference/integer_ops/conv.h, making int8 and int16 variants equally reachable through the QDQ fusion path.
Reproduce
Tested at commit 7c89d33808e55 on Linux x64.
ASAN build configuration (out/asan-release/args.gn):
is_asan = true
is_debug = false
dcheck_always_on = false
target_cpu = "x64"
is_component_build = true
Serve the PoC and launch:
python3 -m http.server 8889 --directory issue_tflite002_grouped_conv_oob --bind 127.0.0.1 &
ASAN_OPTIONS=detect_odr_violation=0 out/asan-release/chrome \
--no-sandbox \
--enable-features=WebMachineLearningNeuralNetwork \
--user-data-dir=/tmp/poc-$(date +%s) \
http://127.0.0.1:8889/poc.html
The GPU process crashes within seconds with an ASAN heap-buffer-overflow READ:
==PID==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7d043c8b03e0
READ of size 4 at 0x7d043c8b03e0 thread T78 (ThreadPoolForeg)
#0 tflite::reference_ops::Conv(...) reference/conv.h:90
#1 tflite::ops::builtin::conv::EvalFloat<kReference>(...) conv.cc:995
...
#7 webnn::tflite::GraphImplTflite::ComputeResources::DoDispatch(...) graph_impl_tflite.cc:328
0x7d043c8b03e0 is located 0 bytes after 416-byte region [0x7d043c8b0240,0x7d043c8b03e0)
allocated by thread T84 (ThreadPoolSingl) here:
#1 base::AlignedAlloc(...) aligned_memory.cc:35
#2 webnn::tflite::BufferContent::BufferContent(...) buffer_content_tflite.cc:33
SUMMARY: AddressSanitizer: heap-buffer-overflow reference/conv.h:90 in tflite::reference_ops::Conv(...)
The complete ASAN log is in asan.log.
Credit
Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.