YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
- TensorFlow.js userDefinedMetadata.signature.outputs output node selection hijack
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.0tf.loadGraphModel() + model.predict(tf.tensor([3]))
2. Affected Product
- Package:
@tensorflow/tfjs-converter - Tested version: 4.22.0
- Format: GraphModel β
model.json+*.binweight shard(s) - Not affected: LayersModel (
@tensorflow/tfjs-layers) β does not consumeuserDefinedMetadata
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
weightsManifestare 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 verificationevidence_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-nodein 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.jslines 147β157 (signature override)operation_mapper.jsmapSignatureEntries()(output node map)graph_executor.jsline 104 (this._outputs = graph.outputs)- GHSA: 0 advisories for
@tensorflow/tfjs-converter - OSV: 0 vulnerabilities for
@tensorflow/tfjs-converter(npm)