You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

F-12 β€” Host OOM in @tensorflow/tfjs-layers via attacker-controlled Dense.units (31,935,321Γ— amplification)

Authorized security research artifact disclosed via huntr.com's TensorFlow.js Model Format Vulnerability program. Source commit 7f5309fef0a47545e34049903dbdae0f97285f7e. All capture data was collected against a synthetic /tmp/victim_host/ CI-runner lab β€” no real PII present.

Real impact captured (sanitized)

Node process terminated under realistic host RAM caps β€” exit 134 (SIGABRT)

  • prlimit --as=1GB β†’ exit 134 + V8 stack trace inside Heap::PerformGarbageCollection
  • prlimit --as=2GB β†’ exit 134 + pthread_create(tf_numa_-1_Eigen) failure
  • prlimit --as=4GB β†’ exit 134 + pthread_create(tf_Compute) failure
  • prlimit --as=8GB β†’ JS-level Array buffer allocation failed thrown
  • Amplification ratio: 31,935,321Γ— (single units int β†’ 4-byte-each weight allocation)

All proof data above was captured against a synthetic CI-runner lab at /tmp/victim_host/ (no real PII present). Full capture: F12_REAL_IMPACT_PROOF_2026-06-11.txt.


Summary

A Node.js service that calls tf.loadLayersModel on an attacker-supplied model.json will be OOM-killed by a 540-byte attacker artefact, as @tensorflow/tfjs-layers reads the Dense.units value verbatim from attacker-controlled Keras config and uses it to size the kernel tensor. assertPositiveInteger permits any positive integer up to Number.MAX_SAFE_INTEGER (2⁡³); Dense.build then asks libtensorflow to allocate [inputDim, units] Γ— float32 bytes. A units = 2²⁴ with batch_input_shape = [null, 256] requests 17,179,869,184 bytes (β‰ˆ 16 GiB) β€” measured 16,446 MB RSS in the audit lab. Amplification factor 31,935,321Γ—.

Root Cause

Lines of Code:

In tfjs-layers/src/utils/generic_utils.ts:441-454:

export function assertPositiveInteger(value: number|number[], name: string) {
  if (Array.isArray(value)) {
    util.assert(value.length > 0, ...);
    value.forEach((v, i) => assertPositiveInteger(v, `element ${i+1}…`));
  } else {
    util.assert(
        Number.isInteger(value) && value > 0,
        () => `Expected ${name} to be a positive integer, but got …`);
  }
  // NO upper bound check!
}

The only constraints are Number.isInteger(value) && value > 0. There is no upper bound, so units = 2²⁴ (16,777,216) passes β€” and so would Number.MAX_SAFE_INTEGER.

In tfjs-layers/src/layers/core.ts:240-256 (Dense.build):

public override build(inputShape: Shape|Shape[]): void {
  inputShape = getExactlyOneShape(inputShape);
  const inputLastDim = inputShape[inputShape.length - 1];
  if (this.kernel == null) {
    this.kernel = this.addWeight(
        'kernel', [inputLastDim, this.units], null, this.kernelInitializer,
        this.kernelRegularizer, true, this.kernelConstraint);
    ...
  }
  this.built = true;
}

build runs during loadLayersModel itself (Sequential knows its input shape from batch_input_shape in the saved config), so the unbounded allocation happens at load β€” no predict() call required.

Why this is NOT a duplicate of the GraphModel allocation finding (F-16): F-16 covers creation_executor.ts (Fill/Ones/Zeros/RandomUniform/ Range) β€” the GraphModel path. F-12 covers the LayersModel path β€” Dense.units, Embedding.inputDim, Embedding.outputDim, Conv*.filters, LSTM.units, RNN.units, GRU.units, batchInputShape. Different file (tfjs-layers/src/utils/generic_utils.ts vs tfjs-converter/src/operations/executors/creation_executor.ts), different attacker JSON shape (Keras layer config vs GraphDef Const nodes), different trigger (load-time during Layer.build vs execute-time during model.execute). A maintainer who patches one will ship the other.

Internal Pre-conditions

  1. Victim Node.js process or browser dashboard calls tf.loadLayersModel(<url>) (or tf.loadModel, or any LayersModel-based AutoML / model-marketplace ingestion flow).
  2. The process uses @tensorflow/tfjs-node, @tensorflow/tfjs-node-gpu, or @tensorflow/tfjs (any package that bundles tfjs-layers).
  3. The host's available heap is less than inputDim Γ— units Γ— dtype_bytes for the attacker-chosen units. With units = 2²⁴ and inputDim = 256 that is ~16 GiB β€” well above any commodity host's free heap.

External Pre-conditions

None. The bug is entirely internal to the loader.

Attack Path

  1. Attacker authors a model.json containing a Sequential model with one InputLayer (batch_input_shape: [null, 256]) and one Dense layer with units: 16777216 and kernel_initializer: { class_name: 'Zeros' }. The whole file is 540 bytes.
  2. Attacker delivers the file to the victim β€” a user upload field, a model registry URL, a browser-side tfjs-vis "browse before download" UI, a CI step.
  3. Victim calls tf.loadLayersModel('file:///path/to/uploaded/model.json').
  4. Sequential.fromConfig instantiates the layers. Dense.build runs because the input shape is known statically.
  5. addWeight('kernel', [256, 16777216], …) reaches the libtensorflow allocator, which emits: tensorflow/core/framework/cpu_allocator_impl.cc:82] Allocation of 17179869184 exceeds 10% of free system memory. and then completes the allocation if the host has the space, or aborts with RESOURCE_EXHAUSTED if it does not.
  6. On any host with less than 16 GiB free heap, the process is OOM-killed before loadLayersModel returns to the caller.

Impact

Quantified from the captured PoC run on @tensorflow/tfjs-node@4.22.0:

Metric Value
Attacker model.json size 540 bytes
Server RSS allocated during load 16,446 MB
Amplification factor 31,935,321Γ—
libtensorflow allocator warning Allocation of 17179869184 exceeds 10% of free system memory
Result on a 4 GiB host OOM-kill of the inference process
Result on a 16 GiB host Sustained 16 GiB allocation, near-certain OOM-kill under any concurrent load

Service-level impact:

  • Any tfjs-node service that loads model.json from sources not under full operator control (AutoML pipelines, model marketplaces, file-upload features, browser pages loading models from attacker-controlled CDN paths) can be DoS'd by a single 540-byte file. The bug is purely on the loader path β€” no inference call required.

Extended Impact β€” same-root-cause manifestations

The same assertPositiveInteger accepts unbounded values for every other shape-axis field read from the Keras config. The same one-line fix closes all of them at once:

Layer / param Lookup site Allocation cost
Dense.units core.ts:221 (this finding) inputDim Γ— units Γ— 4 B
Embedding.inputDim embeddings.ts inputDim Γ— outputDim Γ— 4 B
Embedding.outputDim embeddings.ts same
Conv*.filters convolutional.ts prod(kernelShape) Γ— filters Γ— 4 B
LSTM.units / GRU.units / RNN.units recurrent.ts unitsΒ² Γ— 4 B (recurrent kernel)
batchInputShape axes engine/topology.ts:488 prod(axes) Γ— 4 B for the input tensor

A single guard in assertPositiveInteger (or a wrapper assertReasonableShapeDim) that caps each axis at e.g. 2Β²Β² = 4,194,304 elements covers every one.

PoC

The repository ships a package.json so install is one step. Tested on Node 22 + @tensorflow/tfjs-node@4.22.0.

git clone https://huggingface.co/martilaio/tfjs-layers-dense-units-oom-poc
cd tfjs-layers-dense-units-oom-poc
npm install                  # pulls every dep from package.json
node reproduce.js            # minimal canary PoC β€” primitive proven
bash reproduce_real_impact.sh

Captured signal lands in F12_REAL_IMPACT_PROOF_2026-06-11.txt (sanitized; collected against the synthetic /tmp/victim_host/ CI-runner lab).

Mitigation

In tfjs-layers/src/utils/generic_utils.ts:441-454, add an upper-bound check for the shape-dimension call sites:

// Hard ceiling per shape axis. 2**22 β‰ˆ 4 M elements per axis is well above
// any real model and far below the smallest VM's heap budget.
export const MAX_LAYER_SHAPE_DIM = 1 << 22;

export function assertPositiveInteger(value: number|number[], name: string) {
  if (Array.isArray(value)) {
    util.assert(value.length > 0, …);
    value.forEach((v, i) => assertPositiveInteger(v, `element ${i+1}…`));
  } else {
    util.assert(
      Number.isInteger(value) && value > 0 && value <= MAX_LAYER_SHAPE_DIM,
      () => `Expected ${name} to be a positive integer ≀ ${MAX_LAYER_SHAPE_DIM},
             but got ${formatAsFriendlyString(value)}.`);
  }
}

Apply at every layer-config site that reads a shape dimension from the model file (Dense.units, Embedding.inputDim, Embedding.outputDim, Conv*.filters, LSTM.units, RNN.units, GRU.units, batchInputShape).

CVSS

CVSS 3.1 7.5 / High β€” AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H.

A:H β€” process termination. UI:R β€” victim must invoke loadLayersModel. PR:N, AV:N β€” artifact arrives over the network.

Bug classification

  • CWE-1284 (Improper Validation of Specified Quantity in Input)
  • CWE-770 (Allocation of Resources Without Limits or Throttling)

Affected versions

@tensorflow/tfjs-layers ≀ 4.22.0 (bundled in @tensorflow/tfjs-node, @tensorflow/tfjs-node-gpu, @tensorflow/tfjs).

Files in this repository

File Purpose
README.md this disclosure
reproduce.js minimal PoC β€” Dense.units = 1e9 triggers Array buffer allocation failed
reproduce_real_impact.sh host-OOM emulation β€” runs the PoC under prlimit --as=1/2/4/8 GB to capture exit signal
F12_REAL_IMPACT_PROOF_2026-06-11.txt captured exit-code 134 (SIGABRT) at 1/2/4 GB host emulation; V8 / pthread_create stack traces
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support