High watch CVSS 4.3 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in WebML
DescriptionHeap buffer overflow in WebML
ComponentWebML
Sub-componentWebnn
Bug ClassOOB
Action tierwatch — corruption primitive but gated (flag) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker493708165
Fix commitc75f63de7188 (chromium/src) +18/-0
CWECWE-122 (Heap buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Reported2026-03-18 (per vendor advisory)
Fix landed2026-03-20 (public git)
Disclosed2026-04-07
Patch gap18 days — fix public in source before the advisory

Background

WebNN `conv2d` groups
the Web Neural Network convolution operator supports grouped convolution, where the groups attribute partitions input and output channels into independent convolution groups.
`ValidateConv2d`
the shared graph-validation routine (run in the WebNN service) that checks a conv2d operator’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).

Key insight
The validator enforced divisibility of input channels by 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

  1. Construct a malformed grouped conv From a renderer, script builds a conv2d via MLGraphBuilder with groups set so that the declared output channels are not a multiple of groups (e.g. output channels 7, groups 2).
  2. Pass validation The graph reaches ValidateConv2d, which previously only checked input-channel divisibility and accepted the non-divisible output-channel configuration.
  3. Inconsistent group sizing Backend lowering/execution derives per-group output extents from the malformed channel count, so allocated and iterated sizes diverge.
  4. 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

An attacker able to submit a crafted WebNN graph (ordinary web content using the WebNN 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

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

Audit Directions

  • Paired divisibility invariants
    Where 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 operators
    Audit 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 ordering
    Confirm all shape/attribute invariants are validated before any output-size or per-group arithmetic runs, so later computations never operate on an unvalidated configuration.
From c75f63de718803f929ada79ff96e9cb36d1acb2c Mon Sep 17 00:00:00 2001
From: Lynne Jiang <[email protected]>
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 <[email protected]>
Reviewed-by: Phillis Tang <[email protected]>
Reviewed-by: Reilly Grant <[email protected]>
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 [email protected]

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