CVE-2026-7896
Overview
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_RETURNunwraps abase::expectedvalue or returns the error upward, replacing a hardCHECK.
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.
SerializeTemporaryTensorWithByteSizeCheck and propagates the resulting error with base::expected/ASSIGN_OR_RETURN instead of CHECK-assuming success.Attack Path
- 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.
- Build reaches the cast path
GraphBuilderTfliteserializes the graph output and enters theoutput_tensor_info.data_type == TFLITE_FLOAT16 && is_graph_outputbranch that allocates the temporary cast tensor. - Overflow the byte-size computation
Pre-patch,
SerializeTemporaryTensorcomputes the byte size from the large dimensions and overflows, producing an undersized temporary tensor accepted without validation — the integer overflow. (inferred) - 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
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
| Function | Change | Notes |
|---|---|---|
CanFuseActivationAndGetOutputservices/webnn/tflite/graph_builder_tflite.cc |
modified | |
CanFuseQuantizeAndGetOutputservices/webnn/tflite/graph_builder_tflite.cc |
modified |
Files Changed
services/webnn/tflite/graph_builder_tflite.cc
Audit Directions
- Unchecked size/byte-size computationsAudit WebNN/TFLite serialization for any
SerializeTemporaryTensor-style allocation whose byte size is derived from operand dimensions without theWithByteSizeCheckvariant, especially on paths reachable from attacker-controlled graph shapes. - `CHECK`-assumed successFlag
CHECK(result.has_value())on serialization results that depend on attacker input; these should becomeASSIGN_OR_RETURN/base::expectedso overflow/validation failures abort the build instead of trusting the value. - Fusion/cast emission pathsReview the
CanFuse*AndGetOutputfamily and other spots that emit cast/temporary tensors for graph outputs, confirming each dimension-derived allocation is validated and its error propagated.
Patch
From db6bda50f023057ffa82845f232852dea0f271e1 Mon Sep 17 00:00:00 2001 From: Lynne Jiang <[email protected]> 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 <[email protected]> Reviewed-by: Phillis Tang <[email protected]> Commit-Queue: Lynne Jiang <[email protected]> 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;
Original Bug Report
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 ([email protected])
- 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
- Commit:
ded4396ef94db6b715dda888751563a079f5497e - Date: 2024-03-12
- Author: Junwei Fu ([email protected])
- Subject: webnn: Support Conv2d in TFLite converter
- Review: https://chromium-review.googlesource.com/c/chromium/src/+/5337479
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
- Commit:
00516fef984120c951e854895bbbc80682f8b9d0 - Date: 2025-10-31
- Author: Lynne Jiang ([email protected])
- Subject: webnn: Enable temp tensor byte size check.
- Review: https://chromium-review.googlesource.com/c/chromium/src/+/7092679
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.