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

Overview

Critical
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in WebML
DescriptionHeap buffer overflow in WebML
ComponentWebML
Sub-componentM-146
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — 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.
Tracker483445078
Fix commit1154ae8178f0 (external/github.com/google/XNNPACK) +26/-13
CWECWE-122, CWE-787 (Heap buffer overflow, Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedTobias Wienand
Reported2026-02-10 · bounty $33000 (per vendor advisory)
Fix landed2026-02-24 (public git)
Disclosed2026-03-10
Patch gap14 days — fix public in source before the advisory

Background

XNNPACK
the optimized neural-network operator library that backs Chrome’s WebML/WebNN acceleration; graphs are held in a xnn_subgraph of xnn_value tensors and xnn_node operators.
Operator fusion
a subgraph rewrite pass that replaces a minimum/maximum node (which caps a tensor against a static bound) with a single-input clamp operator for speed.
Broadcasting
a binary op with mismatched operand ranks stretches the smaller operand’s shape to match the larger, so the output rank equals the larger of the two input ranks.
Static scalar
a compile-time-constant value whose total element count is 1 (xnn_shape_multiply_all_dims == 1), the only form the clamp fusion is meant to absorb as its bound.

Root Cause Analysis

The min/max-to-clamp rewrite in src/subgraph.c identified the static-scalar operand (swapping the input/arg pointers when the constant was on the inputs[0] side) and then replaced the two-input minimum/maximum node with a single-input clamp, folding the scalar in as the cap constant. However, it never checked the rank of the scalar argument relative to the input. When the static “scalar” has more dimensions than the input (e.g. a rank-N constant of one element versus a lower-rank input), the original minimum/maximum broadcasts the input up to the argument’s rank, so the node’s output has that higher rank. clamp is single-input and non-broadcasting, so it preserves the input’s (lower) rank, meaning the fused node produces a tensor of the wrong shape/rank. Downstream nodes that were built to consume the broadcast (higher-rank) shape then read and write based on the larger dimensions against a buffer sized for the smaller one, yielding an out-of-bounds access.

The fix computes arg_is_static_scalar/input_is_static_scalar symmetrically and, after normalizing which operand is the scalar, bails out (return xnn_status_success, no rewrite) whenever arg_value->shape.num_dims > input_value->shape.num_dims.

Key insight
A single-element constant is not necessarily a rank-0 scalar: a one-element tensor of high rank still drives broadcasting, so element-count equality (== 1) is an insufficient proxy for “safe to fold into a non-broadcasting clamp” — rank must also be checked.

Attack Path

  1. Construct a graph Craft a WebNN/WebML model whose minimum or maximum op pairs a lower-rank input with a static, single-element constant of higher rank so broadcasting is in effect.
  2. Trigger fusion Compile the graph so XNNPACK’s subgraph rewrite pass matches the min/max pattern and replaces it with a clamp, dropping the broadcast to the higher rank.
  3. Shape mismatch The fused clamp emits a tensor at the input’s lower rank while later nodes still assume the broadcast dimensions, creating a size disagreement between producer and consumers.
  4. Out-of-bounds access Run inference so downstream operators index/copy against the mismatched shape, reading or writing past the allocated tensor buffer.
  5. Exploit Shape the overflow to corrupt adjacent heap data for information disclosure or control-flow hijack within the renderer/GPU process.

Impact Assessment

This is a critical out-of-bounds memory access reachable from attacker-controlled model definitions submitted through the WebML/WebNN surface. Because the corruption stems from a wrong-rank tensor flowing through subsequent operators, it can yield heap out-of-bounds reads and writes, enabling memory disclosure and potentially remote code execution. It is triggerable purely from web content that builds and executes a malicious neural-network graph.

Changed Functions

FunctionChangeNotes
if
src/subgraph.c
modified

Files Changed

  • src/subgraph.c
  • test/subgraph/rewrites.cc

Audit Directions

  • Element-count vs. rank checks
    Flag any fusion/rewrite that treats xnn_shape_multiply_all_dims(...) == 1 as “scalar” without also comparing shape.num_dims, since one-element high-rank tensors still broadcast.
  • Broadcasting-to-single-input conversions
    Audit every rewrite that collapses a two-input broadcasting operator (minimum, maximum, binary elementwise) into a single-input form to confirm output rank/shape is preserved for downstream consumers.
  • Operand swaps before shape validation
    Review swap_value_pointers-style normalization to ensure shape/rank invariants are re-validated on the swapped operands before the node is rewritten.
From 1154ae8178f0efc634cd1e8a681646dc22973255 Mon Sep 17 00:00:00 2001
From: Reilly Grant <reillyg@google.com>
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 to...@gmail.com

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