You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

F-1 β€” Arbitrary file read in @tensorflow/tfjs-node via attacker-controlled weightsManifest.paths[] in model.json

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)

8 / 8 sensitive files recovered byte-perfect via manifest_entry.paths[i]

  • /etc/passwd (187 B), /etc/hostname (13 B), /proc/version (68 B), /proc/self/status (137 B)
  • ~/.npmrc (91 B), ~/.ssh/id_rsa (269 B), ~/.docker/config.json (264 B)
  • /srv/app/.env (274 B) with GITHUB_TOKEN / NPM_TOKEN / AWS_* / PYPI_TOKEN
  • Every byte-perfect match verified by sha256 equality against planted source

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


Summary

A Node.js service that calls tf.loadGraphModel or tf.loadLayersModel on an attacker-supplied model.json (a model artifact uploaded by a user, fetched from a community registry, or routed via any URL the attacker can influence) will read arbitrary files from the server's filesystem and return their bytes to the calling code as artifacts.weightData β€” including ~/.aws/credentials, /proc/self/environ, K8s service account tokens, SSH keys, and .env files. The root cause is that @tensorflow/tfjs-node's NodeFileSystem.loadWeights joins each attacker-controlled entry of the JSON paths[] array with the model directory using path.join and reads the result with fs.readFile β€” with no containment check preventing absolute paths or .. segments.

Root Cause

Lines of Code:

In tfjs-node/src/io/file_system.ts:184-200:

private async loadWeights(
    weightsManifest: tf.io.WeightsManifestConfig,
    path: string): Promise<[tf.io.WeightsManifestEntry[], ArrayBuffer]> {
  const dirName = dirname(path);
  const buffers: Buffer[] = [];
  for (const group of weightsManifest) {
    for (const path of group.paths) {                    // ← attacker JSON
      const weightFilePath = join(dirName, path);        // ← no containment
      const buffer = await readFile(weightFilePath)...;  // ← reads any file
      buffers.push(buffer);
    }
  }
  return [weightSpecs, toArrayBuffer(buffers)];
}

weightsManifest is parsed from the attacker-controlled model.json. The paths array entries are arbitrary strings the attacker chose. path.join has two relevant misbehaviours:

  1. If the entry is absolute (starts with /), path.join('/safe/dir', '/etc/passwd') returns '/etc/passwd' β€” dirName is silently discarded.
  2. If the entry contains .., no canonicalisation rejects the traversal β€” path.join('/safe/dir', '../../etc/passwd') resolves to /etc/passwd.

Both forms reach fs.readFile, whose bytes flow into buffers, are concatenated by toArrayBuffer, and surface to the caller as artifacts.weightData.

Why this is NOT a duplicate of F-2 (loadBinaryModel variant): F-2 covers the same loadWeights helper reached via the two-argument tf.io.fileSystem([pbPath, manifestPath]).load() entry point used for binary GraphDef + JSON manifest loading (called from loadBinaryModel at file_system.ts:133-163). F-1 covers the one-argument entry point (tf.io.fileSystem(jsonPath).load()) used by tf.loadGraphModel(url) and tf.loadLayersModel(url) and reached via loadJSONModel at file_system.ts:166-181. Different caller, different user-facing API, but the same loadWeights sink. A maintainer patching loadJSONModel only will leave F-2 wide open β€” and vice-versa. Both are disclosed; both require the same shared containment helper.

Internal Pre-conditions

  1. The victim Node.js process calls any of: tf.loadGraphModel(<url>), tf.loadLayersModel(<url>), tf.io.fileSystem(<path>).load(), where the URL/path resolves to a model.json whose weightsManifest is attacker-controlled.
  2. The process uses @tensorflow/tfjs-node (or @tensorflow/tfjs-node-gpu).
  3. The process has read permissions on any file the attacker wants to exfiltrate (the same access as the service identity).

External Pre-conditions

None. The bug is entirely within the loader; no network round-trip during the attack besides what the caller already does to obtain the artifact.

Attack Path

  1. Attacker authors a model.json whose weightsManifest[0].paths[0] contains either an absolute path (/etc/passwd, /var/run/secrets/kubernetes.io/serviceaccount/token) or a relative traversal (../../../../../etc/hostname).
  2. Attacker hosts the model.json somewhere the victim service will read it β€” a user upload field, a community model registry, a GitHub raw URL the service follows, an internal staging bucket.
  3. Victim service calls tf.loadGraphModel('file:///path/to/uploaded/model.json') (or tf.loadLayersModel, or tf.io.fileSystem(...)).
  4. The library follows the manifest path; loadWeights reads the attacker's chosen file and stuffs its bytes into the resulting ModelArtifacts.
  5. Attacker recovers the bytes by:
    • reading them from artifacts.weightData if the calling code echoes errors, partial state, or model info to the attacker;
    • or by chaining with any feature that uses the loaded model β€” e.g. inference on attacker-controlled inputs leaks the weight bytes through the output channel.

Impact

Arbitrary file read on the victim Node.js process, scoped to the service identity. Demonstrated end-to-end against @tensorflow/tfjs-node@4.22.0 β€” captured proof file:

ARBITRARY FILE READ PROVEN β€” first 64 bytes match canary
weightData head: FLAG{TFJS_ARBITRARY_FILE_READ_PROVEN}AUDIT_CANARY_VALUE_12345
Target file What the attacker gets
~/.aws/credentials / ~/.config/gcloud/application_default_credentials.json Cloud takeover
/var/run/secrets/kubernetes.io/serviceaccount/token K8s pod takeover; lateral movement
/proc/self/environ Every secret in the process env: DB URLs, API keys, JWT secrets
/etc/shadow (if running as root) Hash cracking β†’ host takeover
Any .env next to the application Same

Extended Impact β€” same-root-cause manifestations

  • F-2: The same loadWeights is reached via the two-argument loadBinaryModel entry point (file_system.ts:133-163) used for binary GraphDef loading. Independent disclosure; same shared-helper fix closes both.
  • F-5: The Python-side tensorflowjs_converter ships an equivalent unchecked join in read_weights.py:65-74. Same root-cause class, different language/process, distinct CI/CD impact (steals $GITHUB_TOKEN etc.).

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-path-traversal-poc
cd tfjs-node-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 F1_REAL_IMPACT_PROOF_2026-06-11.txt (sanitized; collected against the synthetic /tmp/victim_host/ CI-runner lab).

Mitigation

Apply a containment helper at the single shared sink (loadWeights) and at both call sites (loadJSONModel and loadBinaryModel):

import { resolve, relative, isAbsolute } from 'path';

function safeJoinInsideDir(modelDir: string, attackerPath: string): string {
  if (typeof attackerPath !== 'string' || isAbsolute(attackerPath)) {
    throw new Error('Refusing absolute weight path: ' + attackerPath);
  }
  const full = resolve(modelDir, attackerPath);
  const rel  = relative(resolve(modelDir), full);
  if (rel.startsWith('..') || isAbsolute(rel)) {
    throw new Error('Weight path escapes model dir: ' + attackerPath);
  }
  return full;
}

Then replace join(dirName, path) with safeJoinInsideDir(dirName, path) at file_system.ts:192.

CVSS

CVSS 3.1 7.5 / High β€” AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N.

Bug classification

  • CWE-22 (Improper Limitation of a Pathname to a Restricted Directory)
  • CAPEC-126 (Path Traversal)

Affected versions

@tensorflow/tfjs-node and @tensorflow/tfjs-node-gpu β€” all published versions up to and including 4.22.0 (latest at disclosure time).

Files in this repository

File Purpose
README.md this disclosure
model.json malicious model.json whose weightsManifest.paths[] is a path-traversal string
reproduce.js minimal canary-based PoC β€” proves the primitive in 64 bytes
reproduce_real_impact.js real-impact PoC β€” recovers 8 sensitive files byte-perfect from /tmp/victim_host/
F1_REAL_IMPACT_PROOF_2026-06-11.txt sanitized captured proof (byte counts, sha256 equality) from the real-impact PoC
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support