CVE-2026-8531
Overview
Background
- XNNPACK
- The neural-network operator library (
src/...) underlying Chrome’s WebML/WebNN delegate, where reductions and subgraph optimizations are implemented in C. - Reduction axes
- The list of dimensions a reduce operator collapses; TFLite permits duplicate axes (treated as a set) and axes beyond the input rank (treated as implied extent-1 dims), as the commit message notes.
- `xnn_shape_get_dim` / `reduction_axes`
- The accessor that returns a shape’s dimension at an index, used inside the sum-to-mean rewrite to multiply together the extents of the reduction axes.
- sum⇒mean rewrite (`optimize_common_subgraphs_scaled_sum_to_mean`)
- A subgraph pass that rewrote
mul/div(reduce_sum(x), n)intoreduce_mean(x), computingnby reading the extent of each reduction axis from the input shape.
Root Cause Analysis
The sum⇒mean rewrite computed num_reduced_dims by looping over reduce_node->params.reduce.num_reduction_axes and calling xnn_shape_get_dim(&input_value->shape, reduction_axes[k]) for each axis. Reshaping can change a subgraph after construction so that a reduction axis that was valid at build time now exceeds the (reshaped) input rank, and the validation in reduce-nd.c that rejected out-of-bounds/duplicate axes could not be relied upon at delegation time — so the rewrite indexed the input shape with an out-of-bounds axis, a heap out-of-bounds read of the dimension array.
The fix removes that extent-computing loop entirely, replacing the pass with widen_fp16_accumulators, which widens intermediate tensors to fp32 and inserts an fp16 convert instead of relying on the axis extents. In parallel, reduce-nd.c is deliberately relaxed to allow out-of-bounds and duplicate reduction axes: it drops the per-axis bounds-check loop and the uniqueness-rejection loop, and instead sorts the axes, de-duplicates them, and discards any axis >= num_input_dims, capping num_reduction_axes <= num_input_dims (asserted). The first guard is also changed from num_reduction_axes > num_input_dims to num_reduction_axes > XNN_MAX_TENSOR_DIMS, keeping writes into the fixed-size normalized_reduction_axes[XNN_MAX_TENSOR_DIMS] array in bounds while permitting more axes than input dims.
Attack Path
- Submit a crafted WebNN/WebML graph
A page builds a model containing a
mul/divof areduce_sum/reduce_sum_squaredso the sum⇒mean rewrite is eligible. - Induce a reshape mismatch The graph is reshaped (or constructed) so that a reduction axis references a dimension beyond the input value’s current rank, which could not be screened out at delegation time per the commit.
- Trigger the rewrite
During subgraph optimization,
optimize_common_subgraphs_scaled_sum_to_meanloops over the reduction axes and callsxnn_shape_get_dim(&input_value->shape, reduction_axes[k])with the out-of-range axis. - Heap out-of-bounds read The accessor reads past the input shape’s dimension storage, the heap buffer overflow the advisory reports; any further use of the mis-read value is (inferred) and not shown in the diff.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/operators/reduce-nd.c |
modified | |
forsrc/operators/reduce-nd.c |
modified | |
ifsrc/subgraph.c |
modified | |
forsrc/subgraph.c |
modified |
Files Changed
src/operators/reduce-nd.csrc/subgraph.c
Audit Directions
- Indexing shapes with externally-influenced axesAudit every
xnn_shape_get_dim/ dimension-array access whose index comes fromreduction_axesor similar user-supplied index lists, and confirm the index is validated against the current (post-reshape) rank at the point of use, not only at construction. - Invariants that survive reshapingReview optimizer passes that assume a graph property established at build time (e.g., an axis being in range, a node being equivalent to a mean) still holds after reshaping, which the commit shows can be violated.
- Validation moved vs. removedWhen a bounds/uniqueness check is deleted to match backend semantics, verify a downstream normalization (sort/dedup/clamp) actually neutralizes the now-permitted inputs before they reach any indexing or size computation.
Patch
From abd8e60edf09db5f5ba8e7fa2f1fcab0ae0807e1 Mon Sep 17 00:00:00 2001 From: Reilly Grant <[email protected]> Date: Fri, 20 Mar 2026 17:26:44 -0700 Subject: [PATCH] [M147] Allow redundant reduction axes, and out of bounds reduction axes TFlite allows the same axis to be specified in a reduction multiple times, and expects it to be treated like set, i.e. these should be deduplicated. Because of the way reshaping works, it can be difficult at delegation or subgraph creation time to determine if this is happening, so we can't just not delegate such ops. TFlite also allows specifying reduction of a "scalar" with a reduction axis list of {0} (not {}. This is a super annoying behavior, but we need to handle it, because we can't determine if this is happening at delegation time. I think it is reasonable to simply allow out of bounds reduction axes, and just ignore them. This basically treats that reduction axis as an implied dimension of extent 1, which a lot of other things do already (e.g. binary elementwise ops). As a result of this, I realized that the sum => mean rewrite we do currently is not safe from reshaping. The graph might be equivalent to a mean at the time of construction, but become not so after reshaping. (This would almost certainly be a bug in the client code, but it's a bug that we should not silently fix for the client.) In addition, I think the sum => mean rewrite probably has negligible impact on performance. I locally modified the layer norm benchmark to use a sum + multiply to implement the two means, and the performance impact is actually an improvement (though very small, and that doesn't really make sense): ``` name time/op time/op vs base FP32LayerNorm/M:128/N:256/K:512/NormMask:1/process_time/real_time 73.64m ± 2% 74.00m ± 1% ~ (p=0.394 n=6) FP32LayerNorm/M:128/N:256/K:512/NormMask:2/process_time/real_time 69.82m ± 1% 69.31m ± 1% -0.74% (p=0.041 n=6) FP32LayerNorm/M:128/N:256/K:512/NormMask:3/process_time/real_time 69.82m ± 2% 69.29m ± 1% ~ (p=0.485 n=6) FP32LayerNorm/M:128/N:256/K:512/NormMask:4/process_time/real_time 62.28m ± 1% 61.31m ± 1% -1.56% (p=0.009 n=6) FP32LayerNorm/M:128/N:256/K:512/NormMask:5/process_time/real_time 61.80m ± 1% 61.77m ± 1% ~ (p=0.699 n=6) FP32LayerNorm/M:128/N:256/K:512/NormMask:6/process_time/real_time 60.46m ± 2% 59.42m ± 2% -1.72% (p=0.041 n=6) FP32LayerNorm/M:128/N:256/K:512/NormMask:7/process_time/real_time 60.01m ± 2% 59.42m ± 3% ~ (p=0.240 n=6) geomean 65.21m 64.71m -0.76% ``` To fix this issue, I've replaced this rewrite with a `widen_fp16_accumulators` rewrite, that leaves the subgraph mostly intact, but changes the types of the intermediate tensors to fp32, and inserts a convert to fp16 after the division. (Cherry-picked from commit de3504fd8cfcedf194cd0ae43afb37cdff824aa2.) PiperOrigin-RevId: 884165426 Bug: 492350403 Change-Id: I74b0d03c6ce57674ca9d16e9f93e7f9f3a37108d --- diff --git a/src/operators/reduce-nd.c b/src/operators/reduce-nd.c index fb34c75..818db69 100644 --- a/src/operators/reduce-nd.c +++ b/src/operators/reduce-nd.c @@ -142,12 +142,12 @@ return xnn_status_unsupported_parameter; } - if (num_reduction_axes > num_input_dims) { + if (num_reduction_axes > XNN_MAX_TENSOR_DIMS) { xnn_log_error( "failed to reshape %s operator with %zu reduction axes: the number of " - "reduction axes must not exceed the number of input dimensions %zu", + "reduction axes must not exceed %d", xnn_operator_type_to_string_v2(reduce_op), num_reduction_axes, - num_input_dims); + XNN_MAX_TENSOR_DIMS); return xnn_status_invalid_parameter; } @@ -163,19 +163,6 @@ assert(num_input_dims <= XNN_MAX_TENSOR_DIMS); memcpy(normalized_input_shape, input_shape, num_input_dims * sizeof(size_t)); - for (size_t i = 0; i < num_reduction_axes; i++) { - const int64_t signed_num_input_dims = (int64_t)num_input_dims; - if (signed_num_input_dims <= reduction_axes[i] || - reduction_axes[i] < -signed_num_input_dims) { - xnn_log_error( - "failed to reshape %s operator with #%zu reduction axis of %" PRIi64 - ": the index is out of bounds for a %zuD input shape", - xnn_operator_type_to_string_v2(reduce_op), i, reduction_axes[i], - num_input_dims); - return xnn_status_invalid_parameter; - } - } - size_t normalized_reduction_axes[XNN_MAX_TENSOR_DIMS]; assert(num_reduction_axes <= XNN_MAX_TENSOR_DIMS); for (int i = 0; i < num_reduction_axes; i++) { @@ -186,15 +173,20 @@ qsort(normalized_reduction_axes, num_reduction_axes, sizeof(size_t), cmp_value_size_t); - for (size_t i = 1; i < num_reduction_axes; i++) { - if (normalized_reduction_axes[i] <= normalized_reduction_axes[i - 1]) { - xnn_log_error( - "failed to reshape %s operator with #%zu reduction axis of %" PRIi64 - ": the reduction axes must be unique", - xnn_operator_type_to_string_v2(reduce_op), i, reduction_axes[i]); - return xnn_status_invalid_parameter; + // Remove duplicate reduction axes. + int i = 0; + // The array is sorted, we're done if an axis is bigger than num_input_dims. + for (int j = 1; + j < num_reduction_axes && normalized_reduction_axes[j] < num_input_dims; + ++j) { + // Shift non-duplicate elements forward. + if (normalized_reduction_axes[i] != normalized_reduction_axes[j]) { + normalized_reduction_axes[++i] = normalized_reduction_axes[j]; } } + num_reduction_axes = i + 1; + + assert(num_reduction_axes <= num_input_dims); xnn_normalize_reduction( &num_reduction_axes, normalized_reduction_axes, diff --git a/src/subgraph.c b/src/subgraph.c index 2d785ec..9682395 100644 --- a/src/subgraph.c +++ b/src/subgraph.c @@ -2455,11 +2455,24 @@ return xnn_status_success; } +static void convert_static_value_to_fp32(struct xnn_value* value) { + assert(xnn_value_is_static(value->allocation_type)); + if (value->flags & XNN_VALUE_FLAG_NEEDS_CLEANUP) { + xnn_release_memory(value->data); + } + value->data = xnn_allocate_memory(sizeof(float)); + float data = get_scalar_value_as_float(value); + memcpy(value->data, &data, sizeof(float)); + value->flags |= XNN_VALUE_FLAG_NEEDS_CLEANUP; + value->datatype = xnn_datatype_fp32; +} + // Replace `mul(reduce_sum(x), 1/n)`, `div(reduce_sum(x), n)` or // `mul(reduce_sum_squared(x), 1/n)`, `div(reduce_sum_squared(x), n)` // with `reduce_mean(x)` or `reduce_mean_squared(x)`, respectively. -static enum xnn_status optimize_common_subgraphs_scaled_sum_to_mean( - xnn_subgraph_t subgraph, uint32_t node_id, size_t* changes) { +static enum xnn_status widen_fp16_accumulators(xnn_subgraph_t subgraph, + uint32_t node_id, + size_t* changes) { struct xnn_node* node = &subgraph->nodes[node_id]; if (node->type != xnn_node_type_binary_elementwise || @@ -2468,78 +2481,66 @@ return xnn_status_success; } - struct xnn_value* reduce_value = &subgraph->values[node->inputs[0]]; + struct xnn_value* reduced_value = &subgraph->values[node->inputs[0]]; struct xnn_value* arg_value = &subgraph->values[node->inputs[1]]; if (xnn_shape_multiply_all_dims(&arg_value->shape) != 1 || !xnn_value_is_static(arg_value->allocation_type)) { - if (xnn_shape_multiply_all_dims(&reduce_value->shape) == 1 && - xnn_value_is_static(reduce_value->allocation_type)) { - swap_value_pointers(&reduce_value, &arg_value); + if (xnn_shape_multiply_all_dims(&reduced_value->shape) == 1 && + xnn_value_is_static(reduced_value->allocation_type)) { + swap_value_pointers(&reduced_value, &arg_value); } else { return xnn_status_success; } } // Check that one of the args is a sum or sum2 reduction. - if (!(reduce_value->datatype == xnn_datatype_fp16 || - reduce_value->datatype == xnn_datatype_fp32) || - reduce_value->producer == XNN_INVALID_NODE_ID) { + if (reduced_value->datatype != xnn_datatype_fp16 || + reduced_value->producer == XNN_INVALID_NODE_ID) { return xnn_status_success; } - struct xnn_node* reduce_node = &subgraph->nodes[reduce_value->producer]; + + if (reduced_value->num_consumers > 1 || arg_value->num_consumers > 1) { + // Don't rewrite if we might modify an unrelated consumer. + return xnn_status_success; + } + + struct xnn_node* reduce_node = &subgraph->nodes[reduced_value->producer]; const enum xnn_node_type reduce_node_type = reduce_node->type; if (!(reduce_node_type == xnn_node_type_static_sum || reduce_node_type == xnn_node_type_static_sum_squared)) { return xnn_status_success; } - // Check that the other arg is the product of the dimensions of - // the reduction axes, or its inverse. - struct xnn_value* input_value = &subgraph->values[reduce_node->inputs[0]]; - const float arg_as_float = get_scalar_value_as_float(arg_value); - size_t num_reduced_dims = 1; - for (size_t k = 0; k < reduce_node->params.reduce.num_reduction_axes; k++) { - num_reduced_dims *= xnn_shape_get_dim( - &input_value->shape, reduce_node->params.reduce.reduction_axes[k]); - } - if (!num_reduced_dims) { - return xnn_status_success; - } - const enum xnn_binary_operator binary_operator = node->binary_operator; - float expected_arg = (binary_operator == xnn_binary_multiply) - ? 1.0f / num_reduced_dims - : (float)num_reduced_dims; - if (arg_value->datatype == xnn_datatype_fp16) { - expected_arg = xnn_float16_to_float(xnn_float16_from_float(expected_arg)); - } - if (arg_as_float != expected_arg) { - return xnn_status_success; - } + // Rewrite the internal values to this subgraph to be fp32. + reduced_value->datatype = xnn_datatype_fp32; + convert_static_value_to_fp32(arg_value);
Original Bug Report
Heap-buffer-overflow in XNNPACK
Summary
optimize_common_subgraphs_scaled_sum_to_mean rewrites a broadcasted binary op into a unary reduce_mean without verifying that the singleton constant’s rank doesn’t exceed the reduced tensor’s rank, silently dropping broadcasting semantics so that downstream convolution reshapes produce an undersized buffer and the heap-buffer-overflow.
Details
The WebNN trigger is straightforward: reduceSum(input, {axes:[0,1], keepDimensions:false}) produces a lower-rank tensor, and a following div or mul by a constant shaped [1,1,1,1] is still valid because Blink broadcasts the reduction result back up to rank 4. For the attached PoCs, the reduction result is logically [16,8], while the binary output seen by WebNN/TFLite is [1,1,16,8].
The rewrite in optimize_common_subgraphs_scaled_sum_to_mean treats any static one-element tensor as scalar-like and replaces the binary node with a unary reduce, but it never checks whether the binary op was relying on higher-rank broadcasting:
struct xnn_value* reduce_value = &subgraph->values[node->inputs[0]];
struct xnn_value* arg_value = &subgraph->values[node->inputs[1]];
if (xnn_shape_multiply_all_dims(&arg_value->shape) != 1 ||
!xnn_value_is_static(arg_value->allocation_type)) {
if (xnn_shape_multiply_all_dims(&reduce_value->shape) == 1 &&
xnn_value_is_static(reduce_value->allocation_type)) {
swap_value_pointers(&reduce_value, &arg_value);
} else {
return xnn_status_success;
}
}
...
XNN_RETURN_IF_ERROR(xnn_define_static_reduce_v2(
subgraph,
reduce_node_type == xnn_node_type_static_sum
? xnn_reduce_mean
: xnn_reduce_mean_squared,
num_reduction_axes, reduction_axes, input_value->id,
output_id, reduce_node->flags),
"Failed to create new `Mean` or `Mean Squared` node.");
This is the similar bug class (of issue 483445078 probably) that was fixed for minimum / maximum to clamp: optimize_common_subgraphs_min_max_to_clamp now explicitly rejects rewrites when arg_value->shape.num_dims > input_value->shape.num_dims, but scaled_sum_to_mean still lacks that guard.
Once the binary output ID has been rebound to a unary reduce node, reshape_reduce_operator recomputes the output shape solely from the reduction axes and input dimensions, so it drops the higher-rank broadcasted shape that the original binary operator exported:
size_t num_skip_axis = 0;
for (size_t input_idx = 0; input_idx < input_num_dims; ++input_idx) {
bool is_axis = false;
...
if (!is_axis) {
output_value->shape.dim[input_idx - num_skip_axis] = input_dims[input_idx];
}
}
output_value->shape.num_dims = input_num_dims - num_skip_axis;
const size_t new_size = xnn_runtime_tensor_get_size(output_value);
The spatial consumers then trust that reused value as if it were still NHWC. reshape_convolution_operator reads dim[0..2] unconditionally and forces a 4D output shape:
const size_t batch_size = values[input_id].shape.dim[0];
const size_t input_height = values[input_id].shape.dim[1];
const size_t input_width = values[input_id].shape.dim[2];
...
output_value->shape.dim[0] = batch_size;
output_value->shape.dim[1] = output_height;
output_value->shape.dim[2] = output_width;
output_value->shape.dim[3] = output_pixel_stride;
output_value->shape.num_dims = 4;
Finally, the conv/deconv reshape logic interprets that lower-rank value as 4D NHWC and cause the heap-buffer-overflow in xnn_f32_igemm_minmax_ukernel_10x8__fma3_broadcast.
Bisection
This issue is introduced by the commit https://source.chromium.org/chromium/_/chromium/external/github.com/google/XNNPACK/+/27c1929b344882a6dfb95771ef8ab662f1ad010e, which introduce the incorrect implementation of optimize_common_subgraphs_scaled_sum_to_mean function.
Reproduction
Run chrome from https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1598914.zip with the following command:
./chrome --enable-features=ExperimentalWebMachineLearningNeuralNetwork,WebMachineLearningNeuralNetwork --no-sandbox poc.html
You would observe the heap-buffer-overflow in asan.txt
- https://issuetracker.google.com/issues/483445078
- https://source.chromium.org/chromium/_/chromium/external/github.com/google/XNNPACK/+/27c1929b344882a6dfb95771ef8ab662f1ad010e
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/xnnpack/src/src/subgraph.c;l=2451
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/xnnpack/src/src/subgraph.c;l=2862
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/xnnpack/src/src/subgraph/convolution-2d.c;l=661
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/xnnpack/src/src/subgraph/static-reduce.c;l=149
- https://storage.googleapis.com/chromium-browser-asan/linux-release/asan-linux-release-1598914.zip