CVE-2026-5858
Overview
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 viagraph_builder_tflite.cc. - `FULLY_CONNECTED` fusion
- an optimization that fuses a (de)quantized
gemm/matmul plus its scale/bias into a single TFLiteFULLY_CONNECTEDop 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_shapedescribes the scale tensor;scale_shape[0] == 1means 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.
std::nullopt) when scale_shape[0] == 1, forcing the safe unfused path.Attack Path
- 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). - Trigger fusion
The TFLite graph builder attempts to fuse the quantized
gemmand its dequantize into a singleFULLY_CONNECTEDop with per-channel weights. - 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. - Heap buffer overflow
The
FULLY_CONNECTEDinteger 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
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
| Function | Change | Notes |
|---|---|---|
ifservices/webnn/tflite/graph_builder_tflite.cc |
modified |
Files Changed
services/webnn/tflite/graph_builder_tflite.cc
Audit Directions
- Implicit axis/layout assumptions in kernel fusionAudit 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 consistencyWhere 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 coverageConfirm that each rejected fusion safely falls back to an unfused path, and look for other fusions in
graph_builder_tflite.ccthat lack a corresponding shape guard.
Patch
From fc10b0d6304d7d8f31fea83811aa673a7f13f720 Mon Sep 17 00:00:00 2001
From: Wei Wang <wei4.wang@intel.com>
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 <phillis@chromium.org>
Commit-Queue: Wang, Wei4 <wei4.wang@intel.com>
Reviewed-by: Hu, Ningxin <ningxin.hu@intel.com>
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
Original Bug Report
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
- Commit:
310635cc811ae3a64b3abc184885f6769c84f4d7 - Date: 2022-06-12
- Author: A. Unique TensorFlower
- Subject: TFLite FC Layer Per-channel quantization (Full-Integer i8xi8->i8 or i16xi8->i16)
- Piper: PiperOrigin-RevId: 454517886
- URL: https://github.com/tensorflow/tensorflow/commit/310635cc811ae3a64b3abc184885f6769c84f4d7
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
- Commit:
6e5458b43fdc3685cb17664e6709bd79a97dfb36 - Date: 2025-06-20
- Author: Junwei Fu (junwei.fu@intel.com)
- Subject: webnn: Fuse quantized GEMM into TFLite FULLY_CONNECTED
- Review: https://chromium-review.googlesource.com/c/chromium/src/+/6641102
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.