# TensorFlow.js userDefinedMetadata.signature.outputs output node selection hijack **Target:** TensorFlow.js – Google **Route:** huntr MFV **Format:** GraphModel (.json + .bin) **Field:** `model.json` → `userDefinedMetadata.signature.outputs` --- ## 1. Summary TensorFlow.js `loadGraphModel()` reads `userDefinedMetadata.signature.outputs` from `model.json` without validation and uses it to select which graph node becomes the model output. A model.json that contains an injected `userDefinedMetadata.signature` can redirect `model.predict()` to return the value of a different (intermediate) graph node — while the graph topology, weight binary, and `weightsManifest` remain identical. No warning or error is emitted. **Confirmed with actual TensorFlow.js runtime:** `@tensorflow/tfjs-converter 4.22.0` + `@tensorflow/tfjs-backend-cpu 4.22.0` `tf.loadGraphModel()` + `model.predict(tf.tensor([3]))` --- ## 2. Affected Product - **Package:** `@tensorflow/tfjs-converter` - **Tested version:** 4.22.0 - **Format:** GraphModel — `model.json` + `*.bin` weight shard(s) - **Not affected:** LayersModel (`@tensorflow/tfjs-layers`) — does not consume `userDefinedMetadata` --- ## 3. Vulnerability Details ### Vulnerable code path **`graph_model.js` lines 147–157** — `userDefinedMetadata.signature` fully overrides `artifacts.signature`: ```javascript let signature = this.artifacts.signature; if (this.artifacts.userDefinedMetadata != null) { const metadata = this.artifacts.userDefinedMetadata; if (metadata.signature != null) { signature = metadata.signature; // ← COMPLETE OVERRIDE, no validation } } ``` **`operation_mapper.js` — `mapSignatureEntries()`** converts the injected signature to a node-name map: ```javascript function mapSignatureEntries(entries) { return Object.keys(entries || {}).reduce((prev, curr) => { prev[entries[curr].name] = curr; return prev; }, {}); } ``` **`transformGraph()`** then pushes the named node into `graph.outputs[]`, overriding the default leaf-node selection. **`graph_executor.js` line 104**: `this._outputs = graph.outputs` — the injected intermediate node becomes the model's output. ### Attack A single field addition to `model.json`: ```json "userDefinedMetadata": { "signature": { "outputs": { "output_0": { "name": "Mul" } } } } ``` When `name` matches any node in `modelTopology`, `model.predict()` returns that node's value instead of the intended final output — silently. --- ## 4. Impact - `model.predict()` returns the tensor value of an intermediate graph node rather than the intended final output. - The graph topology, weight binary, and `weightsManifest` are unchanged. - No warning, exception, or log message is emitted. - A downstream consumer receives a wrong tensor value without any indication. **Scope:** GraphModel only. LayersModel is not affected. --- ## 5. Proof of Concept ### Graph (clean and mutant use identical topology) ``` Input (Placeholder, float32, shape [1]) └─→ Mul (Input × Input = x²) └─→ Add (Mul + Mul = 2x²) ← default leaf output ``` For input x = 3: Mul = 9, Add = 18. ### Run ```bash npm install node reproduce_tfjs_signature_outputs_flip.js ``` ### Output ``` clean predict(3) -> 18 (outputNodes: ["Add"]) mutant predict(3) -> 9 (outputNodes: ["output_0"]) OUTPUT_TENSOR_VALUE_FLIP : true OUTPUT_NODE_FLIP : true WARNING_EMITTED : false TFJS_SIGNATURE_OUTPUTS_FLIP_CONFIRMED ``` ### Artifact isolation check ```bash node inspect_tfjs_signature_outputs_hash_matrix.js ``` ``` modelTopology : IDENTICAL weightsManifest : IDENTICAL weight SHA256 : IDENTICAL userDefinedMetadata clean : undefined userDefinedMetadata mutant : {"signature":{"outputs":{"output_0":{"name":"Mul"}}}} CHANGED_FIELD_ONLY : userDefinedMetadata.signature.outputs HASH_MATRIX_PASS ``` --- ## 6. Evidence - `evidence_runtime_results.json` — actual runtime output values (ACTUAL_TFJS_RUNTIME tier) - `evidence_reproducibility.json` — 8/8 reproducibility (5x same-process + 3x subprocess) - `evidence_hash_matrix.json` — SHA256 isolation verification - `evidence_distinctness_matrix.json` — distinctness from 6 prior findings --- ## 7. Distinctness from Prior Findings | Root | Field | Format | Consumer | Effect | Overlap | |---|---|---|---|---|---| | **This finding** | `userDefinedMetadata.signature.outputs` | TFJS GraphModel .json | `graph_model.js` + `operation_mapper.js` | Wrong intermediate node returned by predict() | — | | fc03a614 (SUBMITTED) | `weightsManifest[].weights[].name` | TFJS GraphModel .json | `io_utils.js decodeWeights()` | Wrong weight tensor values at correct output node | NONE | | TFLite SignatureDef | FlatBuffer SignatureDef | TFLite .tflite | C++ Task Library | Output key rename | NONE | | TFLite AssociatedFile | FlatBuffer label file | TFLite .tflite | C++ Task Library Classifier | Label string substitution | NONE | | TFLite NormalizationOptions | FlatBuffer mean/std | TFLite .tflite | C++ Task Library preprocessor | Input rescaled before inference | NONE | | OpenVINO rt_info labels | IR XML rt_info | OpenVINO .xml+.bin | openvino-model-api Python | Class label name substitution | NONE | --- ## 8. Non-Claims - No remote code execution (RCE) - No arbitrary code execution (ACE) - No memory corruption - No scanner bypass as primary claim - Not a LayersModel vulnerability - Does not affect graph topology - Does not affect weight shard bytes - Does not allow creation of graph nodes beyond existing `modelTopology` - Does not affect `@tensorflow/tfjs-node` in a manner distinct from pure-JS backend - Not a duplicate of fc03a614 (distinct field, mechanism, code path, and effect) --- ## 9. Expected Triage Objections and Responses **O1: "userDefinedMetadata is metadata, not a security boundary."** The TFJS loader uses `userDefinedMetadata.signature` to directly determine which graph node is evaluated and returned by `model.predict()`. This is a behavioral control, not a descriptive annotation. The runtime makes no distinction between "authoritative" and "advisory" fields. **O2: "The injected node must already exist in the graph."** Agreed. The constraint only means the attacker must reference a valid node name. Any node in `modelTopology` is a valid target. The attack does not require graph modification. **O3: "This is just model supply-chain manipulation."** The finding is that the `userDefinedMetadata` field — which is distinct from the `weightsManifest` and `modelTopology` fields — carries behavioral authority that is not validated or disclosed to the consumer. **O4: "Duplicate of the weightsManifest duplicate-name root (fc03a614)."** Field, consumer function, code path, and effect are all different. fc03a614 is about weight tensor value collision in `decodeWeights()`; this finding is about output node selection override in `mapSignatureEntries()` + `transformGraph()`. **O5: "Duplicate of TFLite SignatureDef root."** Different format (TFLite FlatBuffer vs JSON), different consumer (C++ vs JavaScript), different runtime path, and different effect (key rename vs node value redirect). **O6: "Only the output key name changes, not the value."** `model.predict()` returns tensor value 9 (Mul) instead of 18 (Add) — confirmed by actual runtime execution. This is a value change, not a key rename. **O7: "Users should hash the full model.json."** The TFJS runtime provides no built-in model.json integrity mechanism and no warning when `userDefinedMetadata.signature` overrides inference behavior. **O8: "A model.json change is obvious if inspected."** The override is a JSON field that is not checked by any standard TFJS inspection API. `model.outputNodes` returns `["output_0"]` (the signature key), not the underlying node name, making the redirect opaque to casual review. --- ## 10. Recommendation Add schema validation for `userDefinedMetadata.signature` in `graph_model.js` before overriding `artifacts.signature`, or emit a warning when `userDefinedMetadata.signature` is present and alters the effective output node set. --- ## 11. References - `graph_model.js` lines 147–157 (signature override) - `operation_mapper.js` `mapSignatureEntries()` (output node map) - `graph_executor.js` line 104 (`this._outputs = graph.outputs`) - GHSA: 0 advisories for `@tensorflow/tfjs-converter` - OSV: 0 vulnerabilities for `@tensorflow/tfjs-converter` (npm)