Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in WebML
DescriptionHeap buffer overflow in WebML
ComponentWebML
Bug ClassOOB
Tracker483445078
Fix commit1154ae8178f0 (external/github.com/google/XNNPACK) +26/-13
CISA KEVNot listed
CreditedTobias Wienand
Disclosed2026-03-10

Changed Functions

FunctionChangeNotes
if
src/subgraph.c
modified

Files Changed

  • src/subgraph.c
  • test/subgraph/rewrites.cc
From 1154ae8178f0efc634cd1e8a681646dc22973255 Mon Sep 17 00:00:00 2001
From: Reilly Grant <[email protected]>
Date: Tue, 24 Feb 2026 18:33:30 +0000
Subject: [PATCH] [M-146] Don't replace minimum/maximum operators with clamp when input is broadcast

When the rank of the minimum/maximum argument is greater than the input rank the broadcasting behavior is necessary for the following nodes in the graph so replacement with a single-input (non-broadcasting) clamp operator is inappropriate.

(Cherry-picked from 82367b51bf738ce41a28515b348c3434eb6d2060.)

PiperOrigin-RevId: 871408578
Bug: 483445078
Change-Id: I6855afcb77d67e425d45105755164f8c4ade7720
---

diff --git a/src/subgraph.c b/src/subgraph.c
index 750e2df..e5107bf 100644
--- a/src/subgraph.c
+++ b/src/subgraph.c
@@ -2866,19 +2866,32 @@
     return xnn_status_success;
   }
 
-  // Check that `arg_value` is a static scalar value.
+  // `arg_value` must be a static scalar value, but we can swap the arguments to
+  // make that true.
   struct xnn_value* input_value = &subgraph->values[node->inputs[0]];
   struct xnn_value* arg_value = &subgraph->values[node->inputs[1]];
-  if (!xnn_value_is_const(arg_value->flags) &&
-      !(xnn_shape_multiply_all_dims(&arg_value->shape) == 1 &&
-        xnn_value_is_static(arg_value->allocation_type))) {
-    if (xnn_value_is_const(input_value->flags) ||
-        (xnn_shape_multiply_all_dims(&input_value->shape) == 1 &&
-         xnn_value_is_static(input_value->allocation_type))) {
+
+  const bool input_is_static_scalar =
+      xnn_value_is_const(input_value->flags) ||
+      (xnn_shape_multiply_all_dims(&input_value->shape) == 1 &&
+       xnn_value_is_static(input_value->allocation_type));
+  const bool arg_is_static_scalar =
+      xnn_value_is_const(arg_value->flags) ||
+      (xnn_shape_multiply_all_dims(&arg_value->shape) == 1 &&
+       xnn_value_is_static(arg_value->allocation_type));
+
+  if (input_is_static_scalar) {
+    if (!arg_is_static_scalar) {
       swap_value_pointers(&arg_value, &input_value);
-    } else {
-      return xnn_status_success;
     }
+  } else if (!arg_is_static_scalar) {
+    return xnn_status_success;
+  }
+
+  if (arg_value->shape.num_dims > input_value->shape.num_dims) {
+    // The min or max operator is broadcasting the input to match the scalar's
+    // rank, so we can't replace the operator.
+    return xnn_status_success;
   }
 
   // Extract the min/max argument.
diff --git a/test/subgraph/rewrites.cc b/test/subgraph/rewrites.cc
index 6029c5f..433cd89 100644
--- a/test/subgraph/rewrites.cc
+++ b/test/subgraph/rewrites.cc
@@ -620,9 +620,9 @@
         uint32_t static_min_value_id;
         uint32_t static_max_value_id;
         std::tie(static_min_tensor, static_min_value_id) =
-            add_static_tensor<float>(rng, subgraph, {1});
+            add_static_tensor<float>(rng, subgraph, {});
         std::tie(static_max_tensor, static_max_value_id) =
-            add_static_tensor<float>(rng, subgraph, {1});
+            add_static_tensor<float>(rng, subgraph, {});
 
         // Add the binary `minimum` op.
         uint32_t min_capped_value_id =
@@ -674,9 +674,9 @@
         uint32_t static_min_value_id;
         uint32_t static_max_value_id;
         std::tie(static_min_tensor, static_min_value_id) =
-            add_static_tensor<float>(rng, subgraph, /*shape=*/{1});
+            add_static_tensor<float>(rng, subgraph, /*shape=*/{});
         std::tie(static_max_tensor, static_max_value_id) =
-            add_static_tensor<float>(rng, subgraph, /*shape=*/{1});
+            add_static_tensor<float>(rng, subgraph, /*shape=*/{});
 
         // Add the binary `maximum` op.
         uint32_t max_capped_value_id =
Loading diff…

Original Bug Report

reported by [email protected]

Heap Buffer Overflow in TFLite + XNNPack via WebNN


Report description

Heap Buffer Overflow in TFLite + XNNPack via WebNN


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://source.chromium.org/chromium/chromium/src/+/main:third_party/xnnpack/src/src/subgraph/fully-connected.c


The problem

Please describe the technical details of the vulnerability

Building Chromium with ASAN

Environment

Ubuntu + AMD processor (for completeness, shouldn’t matter)

Base Chromium revision

a0062e558d37e03d9129522e5a3c6c29946d8195 (2026-02-10)

GN args

is_asan = true
is_debug = false
symbol_level = 1

Build

cd chromium/src
gn gen out/asan_shell
autoninja -C out/asan_shell content_shell

Running the PoC

WebNN is behind a feature flag. The GPU process is a separate process, so its ASAN errors don’t appear on the main process stderr. Setting ASAN_OPTIONS with log_path captures per-process ASAN output to disk.

ASAN_OPTIONS="log_path=/tmp/asan_log:detect_leaks=0" ./out/asan_shell/content_shell --enable-features=WebMachineLearningNeuralNetwork file:///path/to/poc.html

The GPU process crashes during graph compilation. ASAN output is at /tmp/asan_log.<gpu_pid>. The poc.html file is included in this report.

If the sandbox is unavailable (e.g., AppArmor restricts unprivileged user namespaces), add --no-sandbox. On a headless machine, prefix the command with xvfb-run -a to provide a virtual X display.

ASAN output

==321087==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x775ffc4c6540 at pc 0x5a4f845042c6 bp 0x754f909f9710 sp 0x754f909f9708
WRITE of size 8 at 0x775ffc4c6540 thread T71 (ThreadPoolForeg)
    #0 reshape_fully_connected_operator     third_party/xnnpack/src/src/subgraph/fully-connected.c:796:32
    #1 xnn_reshape_runtime                  third_party/xnnpack/src/src/runtime.c:921:30
    #2 SubgraphPrepare                      third_party/tflite/src/tensorflow/lite/delegates/xnnpack/xnnpack_delegate.cc:1258:16
    #3 PrepareOpsStartingAt                 third_party/tflite/src/tensorflow/lite/core/subgraph.cc:1540:44
    #4 PrepareOpsAndTensors                 third_party/tflite/src/tensorflow/lite/core/subgraph.cc:1588:7
    #5 AllocateTensors                      third_party/tflite/src/tensorflow/lite/core/subgraph.cc:1035:25
    #6 ComputeResources::Create             services/webnn/tflite/graph_impl_tflite.cc:221:34
    #7 CreateAndBuildOnBackgroundThread      services/webnn/tflite/graph_impl_tflite.cc:519:20

0x775ffc4c6540 is located 0 bytes after 4160-byte region [0x775ffc4c5500,0x775ffc4c6540)
allocated by thread T71 (ThreadPoolForeg) here:
    #0 malloc
    #1 xnn_allocate_zero_memory             third_party/xnnpack/src/src/xnnpack/allocator.h:37:7
    #2 create_runtime_impl                  third_party/xnnpack/src/src/runtime.c
    #3 SubgraphInit                         third_party/tflite/src/tensorflow/lite/delegates/xnnpack/xnnpack_delegate.cc:1214:14

SUMMARY: AddressSanitizer: heap-buffer-overflow third_party/xnnpack/src/src/subgraph/fully-connected.c:796:32 in reshape_fully_connected_operator

Root cause

The crash is in resize_fully_connected_output_tensor in fully-connected.c:

output->shape.num_dims = input->shape.num_dims;
output->shape.dim[output->shape.num_dims - 1] =           // (A) OOB
    filter->shape.dim[filter_output_channel_index];

for (size_t cur_dim = 0; cur_dim < input->shape.num_dims - 1; cur_dim++) {
    output->shape.dim[cur_dim] = input->shape.dim[cur_dim]; // (B) OOB
}

When the FC input has num_dims == 0, the size_t expression (num_dims - 1) underflows to SIZE_MAX. At (A) this writes 8 bytes at dim[SIZE_MAX]. At (B) the loop iterates up to SIZE_MAX times, writing far past the heap allocation.

How num_dims becomes 0

Three mechanisms interact:

1. ELU emulation. When elu.alpha != 1.0, graph_builder_tflite.cc (SerializeElu) decomposes ELU into six elementary ops: max(0, x) + alpha * (exp(min(0, x)) - 1). The scalar constants 0 and 1 are serialized with empty dimensions (num_dims = 0).

2. Conv2d-to-FC conversion. XNNPACK’s xnn_define_convolution_2d converts 1x1 convolutions (unit stride, no padding) into xnn_define_fully_connected at define time, before any optimization.

3. min/max-to-clamp conversion corrupts input selection. During xnn_subgraph_optimize, optimize_common_subgraphs_min_max_to_clamp converts the binary max(scalar_0, x) and min(scalar_0, x) nodes into unary clamp nodes. To do this, it must determine which input is the scalar argument and which is the pass-through tensor. The PoC’s ELU input is a constant with shape [1,1,1,1] — all dimensions multiply to 1 and it has static allocation, so XNNPACK treats it as a scalar constant. The optimization selects the wrong input: it uses the zero scalar (num_dims = 0) as the clamp’s pass-through input instead of the 4D ELU input.

Verified with instrumentation: after the first optimization iteration (2 changes), the clamp nodes have in=[v15] where v15 is the zero scalar with num_dims = 0. The second iteration’s shape propagation then copies num_dims = 0 through the entire chain (clamp output, exp, sub, mul, add), and the fully-connected node receives input_ndims = 0.

Bisection

The vulnerability requires two components: (1) the ELU alpha decomposition in WebNN’s TFLite backend, and (2) XNNPACK’s min/max-to-clamp subgraph optimization. The bug became reachable when the second component was rolled into Chromium.

Introducing Chromium commit: 2f50380887f7952fe03b602fd14254596b273d0a (“Roll TFLite to Next Green Version”, 2025-11-04), which rolled the XNNPACK submodule from 9ff05d7fc634 to b69a4cf83011.

Introducing XNNPACK commit: e9bc43cef4c8663d2831e5c99b6bf799f09168fa (2025-10-29): “Subgraph rewrites for Binary minimum, maximum, and Unary clamp nodes: Replace Binary minimum/maximum with a static scalar operand with a clamp node, Fuse clamp nodes up into clamping nodes, Remove no-op clamp nodes where possible.”

Verified by building and testing the adjacent commits:

  • f31c6ffb1de2 (parent, XNNPACK 9ff05d7fc634): no crash
  • 2f50380887f79 (XNNPACK b69a4cf83011): heap-buffer-overflow in resize_fully_connected_output_tensor

Impact analysis

Affected platforms

Tested on Linux (x86_64) with the TFLite+XNNPACK backend. The bug is in platform-independent XNNPACK C code, so it should also affect Android (ARM64, x86/x64) and ChromeOS, which use the same TFLite+XNNPACK backend as their primary WebNN implementation.

Impact

  • Attack vector: Any website, no user interaction required beyond navigation. WebNN is behind a feature flag (WebMachineLearningNeuralNetwork), which is not yet enabled by default, but eligible for VRP according to the rules and it is also in origin trial since Jan 31: https://chromium-review.googlesource.com/c/chromium/src/+/7518276.
  • Process: GPU process. On Android, this is unsandboxed. Android is vulnerable because it also uses TFLite + XNNPack. This means this not just affects highly privileged processes, but also non-sandboxed processes!
  • Primitive: The loop at (B) writes dim[0] through dim[SIZE_MAX-1], starting within the allocation and quickly overflowing past the 4160-byte xnn_value array into heap metadata and adjacent objects. Without ASAN, the loop continues until it hits an unmapped page.
  • Consequence: GPU process crash (denial of service). Controlled heap corruption (see below) leading to potential code execution within the GPU process sandbox.

Controlled write primitive

The OOB write is not just an uncontrolled crash. The attacker controls both WHAT is written and WHERE the writes land in the heap. This was verified against a0062e558d37e (2026-02-10) with ASAN content_shell.

Memory layout

The XNNPACK runtime allocates all values in a single contiguous array:

runtime->values = xnn_allocate_zero_memory(
    sizeof(struct xnn_runtime_value) * subgraph->num_values);

Each xnn_runtime_value is 160 bytes (verified empirically with offsetof):

Offset  Field                   Size
------  -----                   ----
+0      void* data              8
+8      gemm_config             8
+16     fp32_data               8
+24     shape.num_dims          8       <-- statement (A) target
+32     shape.dim[0..5]         48      <-- statement (B) starts here
+80     size                    8
+88     quantization            48
+136    id                      4
+140    type                    4
+144    datatype                4
+148    allocation_type         4
+152    flags                   4
+156    first_consumer          4

Statement (A): controlled intra-object write

output->shape.num_dims = input->shape.num_dims;         // = 0
output->shape.dim[output->shape.num_dims - 1] =         // dim[SIZE_MAX]
    filter->shape.dim[filter_output_channel_index];

When num_dims == 0, dim[SIZE_MAX] computes address &dim[0] + (2^64 - 1) * 8. On 64-bit, this wraps to &dim[0] - 8 = &num_dims. Statement (A) writes filter_output_channels (an attacker-controlled JavaScript parameter: the first dimension of the conv2d filter shape) to output->shape.num_dims.

Verified empirically with three PoC variants and post-write readback:

?channels=2:

XNNDBG [A] about to write dim[SIZE_MAX]: value=0x2 (filter_dim[0]) to output[25]
XNNDBG [A] before: output->shape.num_dims = 0
XNNDBG [A] after:  output->shape.num_dims = 2

?channels=65 (0x41):

XNNDBG [A] about to write dim[SIZE_MAX]: value=0x41 (filter_dim[0]) to output[25]
XNNDBG [A] before: output->shape.num_dims = 0
XNNDBG [A] after:  output->shape.num_dims = 65

?channels=1337 (0x539):

XNNDBG [A] about to write dim[SIZE_MAX]: value=0x539 (filter_dim[0]) to output[25]
XNNDBG [A] before: output->shape.num_dims = 0
XNNDBG [A] after:  output->shape.num_dims = 1337

The readback confirms dim[SIZE_MAX] wraps to &num_dims. The attacker fully controls the 8-byte value written. The wraparound address arithmetic was also verified with a standalone C program computing &dim[0] + SIZE_MAX == &num_dims on the actual struct definition.

Statement (B): sequential cross-object heap corruption

for (size_t cur_dim = 0; cur_dim < input->shape.num_dims - 1; cur_dim++) {
    output->shape.dim[cur_dim] = input->shape.dim[cur_dim];
}

The loop iterates from dim[0] to dim[SIZE_MAX-1], writing sequentially past the dim array into the output value’s remaining fields, then into adjacent xnn_runtime_value structs. ASAN catches the first write past the allocation boundary.

Annotated trace from ?channels=2 (26 values, FC output at index 25). Each dim[N] overwrites offset 32 + N*8 within the output struct (verified with offsetof):

dim[N]      value               overwrites (output struct)          reads from (input struct)
------      -----               --------------------------          -------------------------
dim[0..5]   0x0                 shape.dim[0..5] (+32..+72)          input shape dims (all 0)
dim[6]      0x4                 size (+80)                          input->size (4 = sizeof(float32))
dim[7..12]  0x0                 quantization (+88..+128)            input quantization (zeroed)
dim[13]     0x10000000e         id(+136)+type(+140)                 input id=14, type=1 (dense_tensor)
dim[14]     0x200000001         datatype(+144)+alloc_type(+148)     input float32(1), workspace(2)
dim[15]     0x600005200         flags(+152)+first_consumer(+156)    input flags=0x5200, consumer=6
dim[16]     <heap pointer>      FIRST WRITE PAST ALLOCATION -> ASAN

Cross-object corruption

Adding a sigmoid after the conv2d (?mode=crossobj) places values after the FC output in the array. The loop then writes over those adjacent structs. Trace from ?mode=crossobj&channels=1337 (28 values, FC output at index 26, sigmoid value at 27):

dim[N]      value               target: values[27] field at offset    source interpretation
------      -----               ------------------------------------  --------------------
dim[0..15]                      <FC output struct, same as above>
--- struct boundary: dim[16] = offset 160 = start of values[27] ---
dim[16]     0x6c9f55f8e010      values[27].data (+0)                  heap pointer
dim[17..18] 0x0                 values[27].gemm_config,fp32_data      NULL
dim[19..25] 0x0                 values[27].shape (num_dims + dims)    zero shape
dim[26]     0x4                 values[27].size (+80)                 4 bytes (float32)
dim[27..32] 0x0                 values[27].quantization (+88)         zeroed
dim[33]     0x100000010         values[27].id(+136)+type(+140)        id=16, type=1
dim[34]     0x100000001         values[27].datatype+alloc_type        float32(1), static(1)
dim[35]     0x3001              values[27].flags+first_consumer       EXTERNAL_INPUT|STATIC|IS_ZERO
dim[36]     0x0                 FIRST WRITE PAST ALLOCATION -> ASAN

The dim[33] value 0x100000010 reveals the source: the loop reads from input->shape.dim[33], which at offset 32+33*8=296 from the input struct (value 15) crosses into values[16]—the zero scalar constant from ELU decomposition (confirmed by its IS_ZERO flag in 0x3001 = SHAPE_IS_STATIC|IS_ZERO|bit0). The id=16 matches values[16].

The loop completely overwrites all fields of the adjacent value struct, including its data pointer, size, type, and flags. Without ASAN, the loop continues into subsequent structs and only stops when it hits an unmapped page.

Allocation size control

The attacker controls the total allocation size by varying the graph structure. More WebNN operations produce more XNNPACK values:

PoC variant num_values Allocation FC output index
?channels=2 26 4160 bytes 25
?channels=1337 26 4160 bytes 25
?channels=2&padding=10 66 10560 bytes 65
?mode=crossobj 28 4480 bytes 26

Summary

The primitive provides:

  1. Controlled value write (statement A): Attacker sets the conv2d output_channels parameter from JavaScript. This 8-byte value is written to a predictable struct field (num_dims) via unsigned integer underflow wrap-around.
  2. Sequential heap spray (statement B): The loop overwrites all fields of the output struct and all subsequent xnn_runtime_value structs. The values written are deterministic and come from the input value’s struct fields (readable via the annotated trace).
  3. Layout control: The attacker controls the allocation size and the FC output’s position by varying the graph structure.

The cause

What version of Chrome have you found the security issue in?

147.0.7681.0 dev

Yes, it is related to a crash.

Choose the type of vulnerability

Memory Corruption (in a non-sandboxed process)

How would you like to be publicly acknowledged for your report?

Tobias Wienand

View on issue tracker