Critical watch CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

Critical
Severity
8.8
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 (uncommon) — 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.
Tracker493319454
Fix commitfc10b0d6304d (chromium/src) +13/-0
CWECWE-122 (Heap buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Reported2026-03-17 · bounty $43000 (per vendor advisory)
Fix landed2026-03-19 (public git)
Disclosed2026-04-07
Patch gap19 days — fix public in source before the advisory

Background

WebNN / WebML
the Web Neural Network API (services/webnn/) that compiles a web-supplied compute graph into a backend; the TFLite backend builds it via graph_builder_tflite.cc.
`FULLY_CONNECTED` fusion
an optimization that fuses a (de)quantized gemm/matmul plus its scale/bias into a single TFLite FULLY_CONNECTED op with per-channel quantized weights.
Per-channel quantization
each output channel of the filter has its own scale value; the kernel assumes the quantized (per-channel) axis is the output-channel dimension, i.e. axis 0 of the filter/scale.
Scale operand shape
scale_shape describes the scale tensor; scale_shape[0] == 1 means the per-channel axis is effectively not axis 0 (the quantized dimension of the filter is not the output channel).

Root Cause Analysis

When building a fused per-channel quantized gemm into a FULLY_CONNECTED op, the code accepted the filter’s dequantize scale without verifying that the per-channel quantization axis was the output-channel dimension (axis 0). The TFLite integer_ops/fully_connected.h kernel indexes the per-channel scale array by output channel, so it implicitly requires the scale’s first dimension to equal the number of output channels (and thus not be 1). If a caller supplied a per-channel scale whose first dimension is 1 (quantized dimension != 0), the fused kernel would index the scale/quantization data with an output-channel count larger than the actual scale array, producing a heap buffer overflow (inferred OOB access inside the kernel).

The fix adds an early guard: when per_channel_quantization is set, it reads scale_shape from b_dequantize.scale_operand_id and returns std::nullopt if scale_shape[0] == 1, rejecting the fusion so the graph falls back to the unfused operator path that handles the layout safely.

Key insight
The fusion omitted the invariant that the per-channel scale’s leading dimension must be the output-channel axis (size != 1); the fix rejects fusing (returns std::nullopt) when scale_shape[0] == 1, forcing the safe unfused path.

Attack Path

  1. Build a malicious graph Web content using WebNN constructs a graph with a gemm/matmul whose filter is per-channel quantized but whose scale tensor has a leading dimension of 1 (quantized dimension not axis 0).
  2. Trigger fusion The TFLite graph builder attempts to fuse the quantized gemm and its dequantize into a single FULLY_CONNECTED op with per-channel weights.
  3. Layout mismatch reaches the kernel Pre-patch, no check rejects scale_shape[0] == 1, so the fused op is emitted with a per-channel scale array whose size does not match the output-channel count the kernel iterates.
  4. Heap buffer overflow The FULLY_CONNECTED integer kernel indexes the per-channel scale by output channel beyond the scale array’s bounds, causing a heap buffer overflow (inferred downstream of the diff).

Impact Assessment

An attacker able to run WebNN graphs reaches a heap buffer overflow in the WebNN/TFLite backend by fusing a per-channel quantized gemm with a mismatched quantization axis; this executes in the process hosting the WebNN service. The vendor advisory rates this critical (“Heap buffer overflow in WebML”) and does not state in-the-wild exploitation; the overflow itself is the kernel-side consequence of the accepted layout and is marked (inferred) since the diff shows only the rejecting guard, and any resulting write primitive or code execution is (inferred).

Changed Functions

FunctionChangeNotes
if
services/webnn/tflite/graph_builder_tflite.cc
modified

Files Changed

  • services/webnn/tflite/graph_builder_tflite.cc

Audit Directions

  • Implicit axis/layout assumptions in kernel fusion
    Audit every WebNN op that fuses into a backend kernel assuming a specific quantization or broadcast axis; verify the fuser rejects shapes that violate that axis before emitting the fused op.
  • Scale/zero-point vs channel-count consistency
    Where per-channel quantization scale/zero-point arrays are consumed, check that their length is validated against the output-channel dimension rather than trusted.
  • Fusion fall-back coverage
    Confirm that each rejected fusion safely falls back to an unfused path, and look for other fusions in graph_builder_tflite.cc that lack a corresponding shape guard.
From fc10b0d6304d7d8f31fea83811aa673a7f13f720 Mon Sep 17 00:00:00 2001
From: Wei Wang <[email protected]>
Date: Wed, 18 Mar 2026 20:00:51 -0700
Subject: [PATCH] [WebNN] Reject fusing per-channel quantized gemm if the quantized dimension of filter is not 0

The FULLY_CONNECTED's underlying kernels expect the per-channel
quantization axis to be the output channel(axis 0). So reject
fusing per-channel quantized gemm and fall back to the unfused
operators path if the quantized dimension of filter is not 0.

Bug: 493319454
Change-Id: Ib7e1236a535dc6a34d3ff9b9f0124a101bd89dbf
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7673406
Reviewed-by: Phillis Tang <[email protected]>
Commit-Queue: Wang, Wei4 <[email protected]>
Reviewed-by: Hu, Ningxin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1601718}
---

diff --git a/services/webnn/tflite/graph_builder_tflite.cc b/services/webnn/tflite/graph_builder_tflite.cc
index 984596dc..d524a8d 100644
--- a/services/webnn/tflite/graph_builder_tflite.cc
+++ b/services/webnn/tflite/graph_builder_tflite.cc
@@ -2090,6 +2090,19 @@
     return std::nullopt;
   }
 
+  // The FULLY_CONNECTED's underlying kernels expect the per-channel
+  // quantization axis to be the output channel dimension (axis 0). This means
+  // the first dimension of the scale can not be equal to 1.
+  // https://source.chromium.org/chromium/chromium/src/+/main:third_party/litert/src/tflite/kernels/internal/reference/integer_ops/fully_connected.h;l=68;drc=9213607704a73d1e877921d0454abb11f761bdcc
+  if (per_channel_quantization) {
+    const auto& scale_shape =
+        GetOperand(b_dequantize.scale_operand_id).descriptor.shape();
+
+    if (scale_shape[0] == 1) {
+      return std::nullopt;
+    }
+  }
+
   // The a_scale * b_scale should be about the same as c_scale for per-tensor
   // quantization.
   // https://source.chromium.org/chromium/chromium/src/+/main:third_party/tflite/src/tensorflow/lite/kernels/kernel_util.cc;l=303;drc=492dc9719f6e1845f4f5c0553cd5c7651115f671
Loading diff…

Original Bug Report

reported by [email protected]

Heap buffer overflow in TFLite FullyConnectedPerChannel via WebNN quantized GEMM with wrong per-axis quantization dimension

Title

Heap buffer overflow in TFLite FullyConnectedPerChannel via WebNN quantized GEMM with wrong per-axis quantization dimension

Summary

The WebNN-to-TFLite translation layer fuses a quantized GEMM into TFLite’s FULLY_CONNECTED operator without validating that the weight tensor’s per-channel quantization axis is dimension 0 (output channels). An attacker can supply a weight tensor with per-channel quantization along dimension 1 (input channels) instead, causing PrepareImpl to allocate per-channel multiplier and shift arrays sized to the input channel count rather than the output channel count. When the kernel evaluates, it iterates over output channels and reads past the end of those arrays, producing a heap-buffer-overflow in the GPU process. Affected platforms: all where the TFLite WebNN backend is active (Linux, ChromeOS, Android by default; Windows and macOS as a fallback). No special GPU hardware is required.

Bisect

TFLite upstream — FullyConnectedPerChannel introduced

This commit introduced per-channel quantization for FULLY_CONNECTED in a single change: PrepareImpl allocates per_channel_output_multiplier and per_channel_output_shift arrays sized to filter->dims->data[quantized_dimension], with only a self-referential consistency check (scale->size == filter->dims->data[quantized_dimension]) and no enforcement that quantized_dimension == 0. The optimized FullyConnectedPerChannel kernel then iterates over filter->dims->data[0] (output channels) elements, causing a heap OOB read when quantized_dimension != 0 and dim 0 > dim quantized_dimension.

Became web-reachable

This commit added quantized GEMM fusion in the WebNN TFLite graph builder without validating that the weight tensor’s per-channel quantization axis is dimension 0.

Root Cause

WebNN’s dequantizeLinear allows per-channel quantization where the scale tensor has a non-unit dimension indicating the quantization axis. When fusing a quantized GEMM into TFLite’s FULLY_CONNECTED, CanFuseQuantizeAndGetOutput(const mojom::Gemm&) checks only whether B’s scale has more than one element and whether bTranspose is true:

// services/webnn/tflite/graph_builder_tflite.cc:2072-2083
size_t number_of_b_scale =
    GetOperand(b_dequantize.scale_operand_id).descriptor.NumberOfElements();
const bool per_channel_quantization = number_of_b_scale != 1;
if (per_channel_quantization && !gemm.b_transpose) {
  return std::nullopt;
}

There is no validation that B’s quantization axis will map to TFLite FULLY_CONNECTED’s filter dimension 0 (output channels). If B has shape [N, K] and its scale has shape [1, K], SerializeQuantizeParams computes the quantization axis by scanning the scale shape for the non-unit dimension, arriving at axis 1:

// services/webnn/tflite/graph_builder_tflite.cc:7001-7009
for (size_t i = 0; i < scale_shape.size(); ++i) {
  if (scale_shape[i] != 1) {
    axis = (input_rank - scale_shape.size()) + i;   // yields axis = 1
  }
}

TFLite’s fully_connected::PrepareImpl validates only that the scale array length matches the filter size along the declared quantization dimension, which is a self-consistent but semantically wrong check:

// third_party/tflite/src/tensorflow/lite/kernels/fully_connected.cc:453-463
TF_LITE_ENSURE_EQ(context, affine_quantization->scale->size,
                  filter->dims->data[affine_quantization->quantized_dimension]);
// ...
data->per_channel_output_multiplier.resize(per_channel_quantization_size);
data->per_channel_output_shift.resize(per_channel_quantization_size);

For a filter of shape [1024, 2] with quantized_dimension = 1, the arrays are allocated with size 2. The optimized FullyConnectedPerChannel kernel then passes these 2-element arrays as per-row multipliers to the ruy GEMM backend, which expects 1024 entries (one per output row). The ruy kernel reads well past the allocated buffer.

Reproduce

Tested at commit 3ad31ba232d9a804b4de78d788e391f82b40a906. No source modifications required.

Build:

autoninja -C out/asan-release chrome

Windows

ASAN_OPTIONS=detect_odr_violation=0 out/asan-release/chrome.exe ^
  --no-sandbox ^
  --enable-features=WebMachineLearningNeuralNetwork ^
  --disable-features=WebNNOnnxRuntime ^
  --user-data-dir=%TEMP%\poc-tflite005 ^
  --enable-logging=stderr ^
  poc.html

Linux

out/asan-release/chrome \
  --no-sandbox \
  --enable-features=WebMachineLearningNeuralNetwork \
  --enable-logging=stderr \
  poc.html

The GPU process crashes within seconds. ASAN reports a heap-buffer-overflow READ of size 32 in ruy::Kernel8bitAvx2Impl, called from FullyConnectedPerChannel. The complete ASAN log is attached in issue_tflite005/asan.log.

==23132==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x1254fc5da8af
READ of size 32 at 0x1254fc5da8af thread T23
    #0 ruy::Kernel8bitAvx2Impl<32>           kernel_avx2_fma.cc:359
    #5 GemmImplUsingRuy::Run                 cpu_backend_gemm_ruy.h:141
    #6 optimized_integer_ops::FullyConnectedPerChannel  fully_connected.h:95
    #7 fully_connected::EvalQuantized<1>     fully_connected.cc:1510
    #8 fully_connected::Eval<1>              fully_connected.cc:1795
    #12 webnn::tflite::GraphImplTflite::ComputeResources::DoDispatch  graph_impl_tflite.cc:328

0x1254fc5da8af is located 1 bytes before 8-byte region
allocated by thread T23 here:
    #1 std::vector<int>::resize              vector.h:1362
    #2 fully_connected::PrepareImpl          fully_connected.cc:463

SUMMARY: AddressSanitizer: heap-buffer-overflow ruy::Kernel8bitAvx2Impl

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker