| # TensorFlow.js model loader hangs on large string weight specs before weight validation |
|
|
| ## Summary |
|
|
| `@tensorflow/tfjs` 4.22.0 can be forced into CPU-bound processing while loading a small TensorFlow.js model file. A malicious `model.json` can add an unused `dtype: "string"` entry to `weightsManifest` with a very large `shape`, while the paired `weights.bin` remains tiny. |
|
|
| When `tf.loadLayersModel()` loads the model, it decodes all supplied weight specs before strict model-weight matching rejects extra weights. The string-weight decoder loops over the attacker-controlled shape and attempts to read a length prefix for every string element. With missing/truncated string data, this becomes a CPU denial-of-service before the loader can reject the malformed extra weight. |
|
|
| ## Affected Version |
|
|
| - Package: `@tensorflow/tfjs` |
| - Version tested: `4.22.0` |
| - npm `latest` on 2026-06-23: `4.22.0` |
| - Runtime: Node.js `v20.20.2` |
|
|
| ## Vulnerability Type |
|
|
| - CWE-400: Uncontrolled Resource Consumption |
| - CWE-835: Loop with Unreachable Exit Condition, bounded only by attacker-controlled metadata |
|
|
| ## Severity |
|
|
| Medium |
|
|
| `CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H` = 5.5 |
|
|
| ## Proof of Concept |
|
|
| PoC repository: |
|
|
| https://huggingface.co/hacnho/tensorflowjs-string-weight-dos-poc |
|
|
| Run: |
|
|
| ```bash |
| npm ci |
| npm run repro |
| ``` |
|
|
| The PoC generates and loads three TF.js model-file variants: |
|
|
| 1. `control_valid_model`: a normal one-layer Dense model. |
| 2. `numeric_extra_float32_control`: the same model plus an unused large `float32` weight spec. |
| 3. `malicious_extra_string_dos`: the same model plus an unused large `string` weight spec. |
|
|
| The malicious model contains: |
|
|
| ```json |
| { |
| "name": "unused_extra_string", |
| "shape": [50000000], |
| "dtype": "string" |
| } |
| ``` |
|
|
| The weights file is only 8 bytes, the same as the control Dense model weights. |
|
|
| ## Observed Results |
|
|
| Recorded evidence: |
|
|
| - `evidence/tfjs-string-weight-dos-repro-2026-06-23-132907011.json` |
| - `evidence/tfjs-string-weight-timing-2026-06-23-132953595.json` |
|
|
| Key results: |
|
|
| - Control model loads successfully in about 10.8 ms and predicts `[7]`. |
| - Numeric extra `float32` weight with shape `[50000000]` fails cleanly in about 15.3 ms with a tensor size mismatch. |
| - Malicious extra `string` weight with shape `[50000000]` does not return before the 3 second watchdog and is killed with `ETIMEDOUT` / `SIGKILL`. |
|
|
| The timing curve shows linear CPU cost for string weights even when the buffer is empty: |
|
|
| - shape `[100000]`: about 333 ms |
| - shape `[500000]`: about 2026 ms |
| - shape `[1000000]`: about 3272 ms |
|
|
| By comparison, `float32`, `int32`, and `bool` shape `[1000000]` reject in under 1 ms. |
|
|
| ## Root Cause |
|
|
| `tf.loadLayersModel()` decodes all weight specs before strict model-weight matching: |
|
|
| - `source/tf-layers-node-loadlayersmodel-decode-before-match.txt` |
|
|
| The load path calls `decodeModelAndOptimizerWeights()`, which calls `tfc.io.decodeWeights(weightData, specs)` for the entire supplied spec list. Only after decoding does the loader call `model.loadWeights(modelWeights, strict)`. |
|
|
| For string weights, `decodeWeights()` first calls `getWeightBytelength()`. The string branch loops from `0` to `sizeFromShape(spec.shape)` and reads a 4-byte length prefix for every string: |
|
|
| - `source/tf-core-node-decodeweights-getbytelength.txt` |
|
|
| Then `decodeWeight()` has another string-specific loop over the same attacker-controlled shape: |
|
|
| - `source/tf-core-node-decodeweight-string-loop.txt` |
|
|
| There is no early validation that enough bytes remain for the declared string count, and the extra unused weight is decoded before it can be rejected as not belonging to the model. |
|
|
| ## Impact |
|
|
| Any service or client that loads untrusted TensorFlow.js model files can be made unresponsive with a tiny model artifact: |
|
|
| - model registry validators |
| - model upload scanners |
| - conversion services |
| - notebook preprocessing jobs |
| - Node.js applications using `tf.loadLayersModel()` |
| - browser applications that load model files on the main thread |
|
|
| In Node.js, this blocks the worker event loop. Repeated malicious submissions can exhaust worker capacity. In browser contexts, loading on the main thread can freeze the UI. |
|
|
| ## Suggested Fix |
|
|
| - Reject string weight specs when fewer than 4 bytes remain for a length prefix. |
| - Reject non-finite `byteLength` and shape products before continuing. |
| - Enforce a reasonable maximum string element count or byte budget during weight decoding. |
| - Decode only weight specs that are expected by the model, or perform strict matching before decoding extra specs. |
| - Ensure `decodeWeightsStream()` receives the same validation because it uses the same per-string loop pattern. |
|
|
| ## Duplicate Check |
|
|
| I checked the local submitted-report corpus, authenticated Huntr dashboard, public Huntr hacktivity, and public web search before packaging this report. |
|
|
| Local corpus: |
|
|
| - `evidence/local-duplicate-search-2026-06-23.txt` |
| - Result: no matches. |
|
|
| Authenticated Huntr dashboard: |
|
|
| - `evidence/dashboard-duplicate-gate-2026-06-23.json` |
| - 69 submitted links loaded. |
| - Result: no matches. |
|
|
| Public Huntr hacktivity: |
|
|
| - `evidence/hacktivity-title-filter-results-2026-06-23.json` |
| - Terms: `TensorFlow.js`, `TFJS`, `decodeWeights`, `weightsManifest`, `string weight`, `string dtype`, `CompositeArrayBuffer`, `getWeightBytelength`, `loadLayersModel` |
| - Each term scanned 1,997 public reports. |
| - Result: 0 bounty links for every term. |
|
|
| Public web search for exact root-cause terms returned documentation/source references and unrelated usage questions, not an exact public vulnerability report. |
|
|
|
|