dukesp69's picture
Triager-ergonomics: ship package.json + one-line npm install; updated PoC sections
2bd7e3b verified
|
Raw
History Blame Contribute Delete
6.38 kB
metadata
license: other
license_name: huntr-security-research-poc
tags:
  - security
  - poc
  - tensorflow.js
  - tfjs-node
  - path-traversal
  - cwe-22
  - arbitrary-file-read

F-2 β€” Arbitrary file read in @tensorflow/tfjs-node.loadBinaryModel via attacker-controlled binary-manifest path (sister sink of F-1)

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)

Sister sink of F-1: 8 / 8 secrets recovered through a separate code path

  • Same 8 files as F-1, this time through weights[*].paths[*] (binary-format models)
  • Confirms F-1 + F-2 must be patched independently β€” one fix does not cover both

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


Summary

A Node.js service that calls tf.io.fileSystem([pbPath, manifestPath]).load() or tf.loadGraphModel(url) where url is the two-element form [pbPath, manifestPath] (used for TF1-style frozen GraphDef + separate JSON manifest) will read arbitrary files from the host filesystem and return their bytes through artifacts.weightData. The root cause is that @tensorflow/tfjs-node's NodeFileSystem.loadBinaryModel passes the attacker-controlled JSON manifest into the same loadWeights helper as the JSON-model path β€” which joins paths[] entries with path.join and reads the result via fs.readFile, with no containment check for absolute paths or .. segments.

Root Cause

Lines of Code:

In tfjs-node/src/io/file_system.ts:133-163:

protected async loadBinaryModel(): Promise<tf.io.ModelArtifacts> {
  const topologyPath        = this.path[0];                  // .pb file
  const weightManifestPath  = this.path[1];                  // JSON manifest
  // ... stat checks ...
  const modelTopology   = await readFile(this.path[0]);
  const weightsManifest = JSON.parse(await readFile(this.path[1], 'utf8'));
  const [weightSpecs, weightData] =
      await this.loadWeights(weightsManifest, this.path[1]);  // ← attacker JSON
  // ...
}

The loadWeights helper (L184-L200) iterates each weightsManifest[i].paths[j] and calls readFile(join(dirName, path)) β€” no containment. Both absolute paths (which discard dirName entirely) and .. traversals reach readFile.

Why this is NOT a duplicate of F-1: F-1 targets the one-argument entry point reached from loadJSONModel at L166-L181 β€” used by tf.loadGraphModel(url) when url is a single string and by tf.loadLayersModel(url). F-2 targets the two-argument entry point reached from loadBinaryModel at L133-L163 β€” used by tf.loadGraphModel(url) when url is [pbPath, manifestPath] (TF1-style frozen GraphDef pattern) and by direct callers of tf.io.fileSystem([pbPath, manifestPath]).load(). Different caller, different user-facing API. A maintainer patching only one will leave the other open.

Internal Pre-conditions

  1. Victim service calls one of:
    • tf.io.fileSystem([pbPath, manifestPath]).load(),
    • tf.loadGraphModel([pbPath, manifestPath]), on paths where the manifest is attacker-controlled.
  2. Process uses @tensorflow/tfjs-node (or @tensorflow/tfjs-node-gpu).

External Pre-conditions

None.

Attack Path

  1. Attacker writes any valid file (or a 1-byte placeholder) as model.pb and a malicious weights_manifest.json with paths: ['../../../../../../etc/hostname'] (or an absolute path).
  2. Attacker delivers both to the victim (model registry, upload field, etc.).
  3. Victim calls tf.io.fileSystem([pbPath, manifestPath]).load().
  4. loadBinaryModel parses the manifest and passes it to loadWeights, which reads /etc/hostname (or any other file) and stuffs its bytes into artifacts.weightData.

Impact

Arbitrary file read on the Node.js process β€” same primitive as F-1, reached through an independent entry point. Captured proof (F2_REAL_IMPACT_PROOF_2026-06-11.txt):

weightData head: ci-runner-01

(ci-runner-01 = sanitized victim_host's /etc/hostname.)

All chain-attack scenarios from F-1 apply: cloud credentials, K8s service account tokens, SSH keys, /proc/self/environ, .env files.

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-node-loadbinarymodel-path-traversal-poc
cd tfjs-node-loadbinarymodel-path-traversal-poc
npm install                  # pulls every dep from package.json
node reproduce.js            # minimal canary PoC β€” primitive proven
node reproduce_real_impact.js

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

Mitigation

Same shared safeJoinInsideDir helper recommended for F-1 β€” applied at the single loadWeights sink closes both F-1 and F-2 simultaneously.

CVSS

CVSS 3.1 7.5 / High β€” AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N (same as F-1).

Bug classification

  • CWE-22 (Path Traversal)
  • CAPEC-126

Affected versions

@tensorflow/tfjs-node and @tensorflow/tfjs-node-gpu ≀ 4.22.0.

Files in this repository

File Purpose
README.md this disclosure
reproduce.js minimal PoC for the loadBinaryModel sink (the sister of F-1's loadJSONModel)
reproduce_real_impact.js real-impact PoC β€” recovers 8 sensitive files byte-perfect from /tmp/victim_host/
F2_REAL_IMPACT_PROOF_2026-06-11.txt sanitized captured proof from running the real-impact PoC