Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in WebML
DescriptionHeap buffer overflow in WebML
ComponentWebML
Bug ClassOOB
Tracker493708165
Fix commitc75f63de7188 (chromium/src) +18/-0
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-04-07

Changed Functions

FunctionChangeNotes
if
services/webnn/public/cpp/graph_validation_utils.cc
modified

Files Changed

  • services/webnn/public/cpp/graph_validation_utils.cc
  • services/webnn/webnn_graph_impl_unittest.cc
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{
Loading diff…

Regression Test / PoC

shipped with the fix
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{
Loading diff…

Original Bug Report

reported by je...@gmail.com

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

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

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.

View on issue tracker