Hironabe333's picture
Upload 13 files
a147114 verified
|
Raw
History Blame Contribute Delete
8.53 kB

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:

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:

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:

"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

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

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)