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

Overview

Critical
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInteger overflow in Blink
DescriptionInteger overflow in Blink
ComponentBlink
Sub-componentWebnn
Bug ClassInteger Overflow
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.
Tracker493747582
Fix commitdb6bda50f023 (chromium/src) +863/-563
CWECWE-472
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-18 · bounty $43000 (per vendor advisory)
Fix landed2026-03-25 (public git)
Disclosed2026-05-05
Patch gap41 days — fix public in source before the advisory

Background

WebNN / `GraphBuilderTflite`
the Web Neural Network service component (//services/webnn) that serializes a WebNN graph into a TFLite flatbuffer model for execution.
Temporary tensor
an intermediate tensor the builder emits (e.g. for a float32↔float16 cast on a graph output), whose byte size is computed from its dimensions and element type.
`SerializeTemporaryTensor` vs. `SerializeTemporaryTensorWithByteSizeCheck`
the former allocates a temporary tensor without validating the computed byte size; the latter performs an explicit byte-size (overflow) check and returns an error on failure.
`base::expected` / `ASSIGN_OR_RETURN`
Chromium’s error-propagation idiom; ASSIGN_OR_RETURN unwraps a base::expected value or returns the error upward, replacing a hard CHECK.

Root Cause Analysis

When emitting the float32↔float16 cast for a graph-output operand, SerializeOutputTensorInfo created the intermediate tensor with SerializeTemporaryTensor(output_tensor_info.dimensions, ::tflite::TensorType_FLOAT32), which computes the tensor’s byte size from attacker-influenced dimensions without a size/overflow check. A crafted operand with large dimensions could make that byte-size computation overflow, so the allocated temporary tensor is undersized relative to the dimensions recorded for it — an integer overflow that underlies subsequent out-of-bounds access (inferred).

The fix switches the call to SerializeTemporaryTensorWithByteSizeCheck, which validates the byte size and returns an error instead of proceeding, and threads that fallible result up through ASSIGN_OR_RETURN. To carry the error, SerializeOutputTensorInfo and the CanFuse*AndGetOutput family change their return types from TensorInfo/std::optional<TensorInfo> to base::expected<..., std::string>, and the prior CHECK(serialized_operand_result.has_value()) is replaced by ASSIGN_OR_RETURN, so a serialization failure propagates as a graph-build error rather than being assumed valid.

Key insight
The single mistake was allocating a graph-output cast’s temporary tensor without checking its computed byte size for overflow; the fix routes that allocation through SerializeTemporaryTensorWithByteSizeCheck and propagates the resulting error with base::expected/ASSIGN_OR_RETURN instead of CHECK-assuming success.

Attack Path

  1. Submit a crafted WebNN graph Through the WebNN API, supply a graph whose float output operand (requiring a float32↔float16 cast, e.g. a fused-activation or quantize path) has dimensions chosen to overflow the temporary tensor’s byte-size computation.
  2. Build reaches the cast path GraphBuilderTflite serializes the graph output and enters the output_tensor_info.data_type == TFLITE_FLOAT16 && is_graph_output branch that allocates the temporary cast tensor.
  3. Overflow the byte-size computation Pre-patch, SerializeTemporaryTensor computes the byte size from the large dimensions and overflows, producing an undersized temporary tensor accepted without validation — the integer overflow. (inferred)
  4. Out-of-bounds access downstream The undersized tensor is used with its declared (larger) dimensions during model building/execution, leading to out-of-bounds memory access. (inferred)

Impact Assessment

The advisory rates this critical, an integer overflow in Blink/WebNN; it is reached by submitting a crafted graph to the WebNN service, which requires WebNN to be available/enabled and typically executes in a service process. An attacker who overflows the temporary-tensor byte size obtains an undersized allocation usable for out-of-bounds access (inferred); the advisory does not report in-the-wild exploitation, and any memory-corruption or escalation beyond the overflow itself is (inferred).

Changed Functions

FunctionChangeNotes
CanFuseActivationAndGetOutput
services/webnn/tflite/graph_builder_tflite.cc
modified
CanFuseQuantizeAndGetOutput
services/webnn/tflite/graph_builder_tflite.cc
modified

Files Changed

  • services/webnn/tflite/graph_builder_tflite.cc

Audit Directions

  • Unchecked size/byte-size computations
    Audit WebNN/TFLite serialization for any SerializeTemporaryTensor-style allocation whose byte size is derived from operand dimensions without the WithByteSizeCheck variant, especially on paths reachable from attacker-controlled graph shapes.
  • `CHECK`-assumed success
    Flag CHECK(result.has_value()) on serialization results that depend on attacker input; these should become ASSIGN_OR_RETURN/base::expected so overflow/validation failures abort the build instead of trusting the value.
  • Fusion/cast emission paths
    Review the CanFuse*AndGetOutput family and other spots that emit cast/temporary tensors for graph outputs, confirming each dimension-derived allocation is validated and its error propagated.
From db6bda50f023057ffa82845f232852dea0f271e1 Mon Sep 17 00:00:00 2001
From: Lynne Jiang <lyjiang@google.com>
Date: Wed, 25 Mar 2026 11:10:24 -0700
Subject: [PATCH] [webnn] Switch to use SerializeTemporaryTensorWithByteSizeCheck in tflite graph builder.

Bug: 493747582
Binary-Size: Size increase is expected.
Change-Id: I420f1a26507007af0e4c17a5a48b1ea30e1b4c9d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7693793
Reviewed-by: Reilly Grant <reillyg@chromium.org>
Reviewed-by: Phillis Tang <phillis@chromium.org>
Commit-Queue: Lynne Jiang <lyjiang@google.com>
Cr-Commit-Position: refs/heads/main@{#1604964}
---

diff --git a/services/webnn/tflite/graph_builder_tflite.cc b/services/webnn/tflite/graph_builder_tflite.cc
index 8b243bd..22a65aea 100644
--- a/services/webnn/tflite/graph_builder_tflite.cc
+++ b/services/webnn/tflite/graph_builder_tflite.cc
@@ -1147,11 +1147,12 @@
   return input_tensor_info;
 }
 
-GraphBuilderTflite::TensorInfo GraphBuilderTflite::SerializeOutputTensorInfo(
+auto GraphBuilderTflite::SerializeOutputTensorInfo(
     OperandId operand_id,
     QuantizateParametersOffset quantize_params,
     bool operation_supports_float16,
-    std::optional<::tflite::TensorType> override_tensor_type) {
+    std::optional<::tflite::TensorType> override_tensor_type)
+    -> base::expected<TensorInfo, std::string> {
   auto it = operand_to_tensor_info_map_.find(operand_id);
   if (it != operand_to_tensor_info_map_.end()) {
     return it->second;
@@ -1186,10 +1187,9 @@
       tensor_type = *override_tensor_type;
     }
   }
-  const auto serialized_operand_result =
-      SerializeOperand(operand_id, quantize_params, tensor_type);
-  CHECK(serialized_operand_result.has_value());
-  const TensorInfo output_tensor_info = serialized_operand_result.value();
+
+  ASSIGN_OR_RETURN(const TensorInfo output_tensor_info,
+                   SerializeOperand(operand_id, quantize_params, tensor_type));
   // Insert a TFLite cast operator to convert float32 to float16 if the operand
   // is graph output and the current operation doesn't support float16
   // inference or override to float32 (for example the output tensor of
@@ -1213,8 +1213,10 @@
   if (output_tensor_info.data_type == ::tflite::TensorType_FLOAT16 &&
       (!operation_supports_float16 || override_float32_type) &&
       is_graph_output) {
-    const TensorIndex temporary_tensor_index = SerializeTemporaryTensor(
-        output_tensor_info.dimensions, ::tflite::TensorType_FLOAT32);
+    ASSIGN_OR_RETURN(
+        const TensorIndex temporary_tensor_index,
+        SerializeTemporaryTensorWithByteSizeCheck(
+            output_tensor_info.dimensions, ::tflite::TensorType_FLOAT32));
     graph_output_cast_operators_.emplace_back(SerializeCastOperation(
         temporary_tensor_index,
         /*input_tensor_type=*/::tflite::TensorType_FLOAT32,
@@ -1638,7 +1640,8 @@
           OperandDataType::kFloat32);
 }
 
-std::optional<GraphBuilderTflite::FusedActivationOutputInfo>
+base::expected<std::optional<GraphBuilderTflite::FusedActivationOutputInfo>,
+               std::string>
 GraphBuilderTflite::CanFuseActivationAndGetOutput(OperandId output_operand_id) {
   std::optional<OperationId> next_op_id =
       GetSoleDependentOperationId(output_operand_id);
@@ -1666,15 +1669,14 @@
     return std::nullopt;
   }
 
+  ASSIGN_OR_RETURN(const TensorInfo output_tensor_info,
+                   SerializeOutputTensorInfo(activation_output_operand_id));
   fused_ops_to_skip_.insert(*next_op_id);
-
-  return FusedActivationOutputInfo(
-      activation_output_operand_id,
-      SerializeOutputTensorInfo(activation_output_operand_id).index,
-      *activation_type);
+  return FusedActivationOutputInfo(activation_output_operand_id,
+                                   output_tensor_info.index, *activation_type);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(const mojom::Clamp& clamp,
                                                 bool is_emulated) {
   if (!IsDequantizeOutput(clamp.input_operand_id)) {
@@ -1715,7 +1717,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(
     const mojom::Conv2d& conv2d,
     std::optional<OperandId> activation_output_operand_id) {
@@ -1830,7 +1832,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(const mojom::Concat& concat) {
   std::optional<OperandDataType> first_input_quantized_type;
   if (!std::ranges::all_of(
@@ -1897,7 +1899,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(
     const mojom::ElementWiseBinary& binary) {
   if (!IsDequantizeOutput(binary.lhs_operand_id) ||
@@ -2004,7 +2006,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(const mojom::Elu& elu) {
   if (!IsDequantizeOutput(elu.input_operand_id)) {
     return std::nullopt;
@@ -2041,7 +2043,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(const mojom::Gather& gather) {
   if (!IsDequantizeOutput(gather.input_operand_id)) {
     return std::nullopt;
@@ -2073,7 +2075,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(const mojom::Gemm& gemm) {
   // TODO(crbug.com/372932099): Fuse quantized gemm when gemm.alpha or gemm.beta
   // isn't 1.0.
@@ -2179,7 +2181,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(const mojom::Pad& pad) {
   // For edge padding mode, it is not supported in tflite schema.
   if (pad.mode->which() == mojom::PaddingMode::Tag::kEdge) {
@@ -2223,7 +2225,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(const mojom::Pool2d& pool2d) {
   // L2Pool doesn't support quantized implementation.
   CHECK_NE(pool2d.kind, mojom::Pool2d::Kind::kL2Pool2d);
@@ -2275,7 +2277,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(const mojom::Reduce& reduce) {
   // QDQ fusion only support reduce operation with kSum, kMean, kMax and kMin
   // kinds.
@@ -2316,7 +2318,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(
     const mojom::Resample2d& resample2d) {
   if (!IsDequantizeOutput(resample2d.input_operand_id)) {
@@ -2356,7 +2358,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(const mojom::Reshape& reshape) {
   if (!IsDequantizeOutput(reshape.input_operand_id)) {
     return std::nullopt;
@@ -2405,7 +2407,7 @@
   return SerializeQuantizedOutput(*next_op);
 }
 
-std::optional<GraphBuilderTflite::TensorInfo>
+base::expected<std::optional<GraphBuilderTflite::TensorInfo>, std::string>
 GraphBuilderTflite::CanFuseQuantizeAndGetOutput(const mojom::Slice& slice) {
   if (!IsDequantizeOutput(slice.input_operand_id)) {
     return std::nullopt;
Loading diff…

Original Bug Report

reported by je...@gmail.com

Integer overflow in XNNPack tensor size computation leads to heap OOB write in the GPU process via WebNN pool2d

Integer overflow in XNNPack tensor size computation leads to heap OOB write in the GPU process via WebNN pool2d

Summary

When the WebNN TFLite backend translates a maxPool2d (or averagePool2d) operation with explicit padding, it inserts an internal PAD node whose output tensor dimensions are not checked against the byte-size limit. XNNPack’s get_tensor_size function then computes the tensor’s byte size by multiplying the element count by the datatype width in a uint64_t, which silently wraps around for sufficiently large shapes. The memory planner allocates a workspace based on the wrapped value while the PAD kernel writes according to the true dimensions, producing an immediate heap buffer overflow in the GPU process. Affected platforms: all platforms where the TFLite WebNN backend is active (Linux, macOS, Windows, ChromeOS).

Bisect

XNNPack upstream — xnn_shape_multiply_all_dims never overflow-checked

  • Commit: 0630d2941ca4b96b5f318ac3d03893922d55c3fa
  • Date: 2021-09-28
  • Author: Marat Dukhan (maratek@google.com)
  • Subject: Refactor creation and setup of Operators from Nodes
  • PiperOrigin-RevId: 399455001

This function is a simple batch_size *= shape->dim[i] loop with no overflow check, and has never had one. get_tensor_size multiplies the result by xnn_datatype_size_bits in uint64_t, which wraps for sufficiently large shapes, causing workspace underallocation.

Became web-reachable — InsertPadOperation introduced with unchecked SerializeTemporaryTensor

This commit introduced InsertPadOperation using SerializeTemporaryTensor() (no byte-size check) to create internal PAD output tensors. The function validates individual dimensions fit in int32_t but never checks the total byte size against tensor_byte_length_limit. The pool2d path was added subsequently and reuses the same function.

Checked variant introduced but InsertPadOperation NOT updated

This commit introduced SerializeTemporaryTensorWithByteSizeCheck() and updated one call site (float16-to-float32 cast dequantize path), but InsertPadOperation and other callers of SerializeTemporaryTensor were not updated.

Root Cause

The vulnerability spans two components: the Chromium WebNN translation layer and XNNPack’s tensor size arithmetic.

When maxPool2d or averagePool2d receives explicit padding that cannot be expressed as TFLite SAME or VALID, SerializePool2d calls InsertPadOperation to emit a separate PAD node ahead of the pool. This function computes the padded output dimensions with checked arithmetic, verifying each individual dimension fits in int32_t, then creates the tensor:

// services/webnn/tflite/graph_builder_tflite.cc:3563-3565
const TensorIndex output_tensor_index =
    SerializeTemporaryTensor(output_shape, input_tensor_info.data_type,
                             input_tensor_info.quantize_params);

SerializeTemporaryTensor accepts any dimensions without validating total byte size:

// services/webnn/tflite/graph_builder_tflite.cc:3063-3074
TensorIndex GraphBuilderTflite::SerializeTemporaryTensor(
    base::span<const int32_t> dimensions,
    ::tflite::TensorType tensor_type,
    QuantizateParametersOffset quantize_params) {
  const TensorIndex temporary_tensor_index =
      base::checked_cast<TensorIndex>(tensors_.size());
  tensors_.emplace_back(::tflite::CreateTensor(
      builder_, builder_.CreateVector<int32_t>(dimensions), tensor_type,
      /*buffer=*/0, /*name=*/0, quantize_params));
  return temporary_tensor_index;
}

The codebase already provides a safe alternative, SerializeTemporaryTensorWithByteSizeCheck, which validates byte length against context_properties_.tensor_byte_length_limit before creation. InsertPadOperation does not use it.

XNNPack delegates the resulting PAD node and computes workspace requirements in get_tensor_size:

// third_party/xnnpack/src/src/tensor.c:779-781
uint64_t size_bits = xnn_datatype_size_bits(datatype);
size_bits *= xnn_shape_multiply_all_dims(shape);
return (size_bits + 7) >> 3;

xnn_shape_multiply_all_dims returns a size_t product of all dimensions:

// third_party/xnnpack/src/src/tensor.c:636-644
size_t xnn_shape_multiply_all_dims(const struct xnn_shape* shape) {
  size_t batch_size = 1;
  for (size_t i = 0; i < shape->num_dims; i++) {
    batch_size *= shape->dim[i];
  }
  return batch_size;
}

Neither multiplication is overflow-checked. For a padded tensor shape [1, 32768, 32770, 536838146], the element count is 576,460,752,303,516,672. Multiplying by 32 bits yields 18,446,744,073,712,533,504, which exceeds UINT64_MAX by exactly 4,194,304. The wrapped size_bits value is 4,194,304, producing a workspace allocation of 524,288 bytes (512 KB).

The PAD operator’s SSE2 fill kernel then writes zero-padding into this workspace following the true 4D strides. After approximately 131,072 float writes the kernel crosses the 512 KB boundary and corrupts adjacent heap memory, which ASAN detects as a heap-buffer-overflow WRITE.

The PoC constructs this condition through maxPool2d with the following parameters: input [1, 1, 1, 536838146], window [32768, 32770], strides [32768, 32770], and padding [32767, 0, 32769, 0]. The pool’s output shape is [1, 1, 1, 536838146], which passes all WebNN validation, while the internal padded shape overflows XNNPack’s size computation. Using strides equal to the window dimensions keeps the max-pooling indirection buffer at approximately 24 GB, which is large but within the reach of servers and workstations.

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 8888 --directory issue_tflite005_xnnpack_pad_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:8888/poc.html

Graph construction takes approximately 60 to 90 seconds (the max-pooling indirection buffer is around 24 GB). After dispatch, the GPU process crashes immediately with an ASAN heap-buffer-overflow WRITE:

==PID==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7b7439c02820
WRITE of size 16 at 0x7b7439c02820 thread T79 (ThreadPoolForeg)
    #0 xnn_xx_fill_ukernel__sse2_u64 xx-fill-sse2-u64.c:32
    ...
    #9 xnn_run_operator_with_index operator-run.c:2147
    #10 xnn_invoke_runtime runtime.c:1143
    #11 tflite::xnnpack::SubgraphInvoke(...) xnnpack_delegate.cc:1429
    ...
    #15 webnn::tflite::GraphImplTflite::ComputeResources::DoDispatch(...) graph_impl_tflite.cc:328

0x7b7439c02820 is located 0 bytes after 524320-byte region [0x7b7439b82800,0x7b7439c02820)
allocated by thread T79 (ThreadPoolForeg) here:
    #1 xnn_aligned_allocate allocator.c:46
    #2 xnn_plan_memory allocator.h:60
    #3 tflite::xnnpack::SubgraphPrepare(...) xnnpack_delegate.cc:1266

SUMMARY: AddressSanitizer: heap-buffer-overflow xx-fill-sse2-u64.c:32 in xnn_xx_fill_ukernel__sse2_u64

The complete ASAN log is in asan.log.

Credit

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

View on issue tracker