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) withGITHUB_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:
- tfjs-node/src/io/file_system.ts L184-L200 (
loadWeights) - tfjs-node/src/io/file_system.ts L166-L181 (
loadJSONModel) β caller, invoked via the standardtf.io.fileSystem(path).load()andtf.loadGraphModel('file://...')entry points - tfjs-node/src/io/file_system.ts L41 (
NodeFileSystemclass)
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:
- If the entry is absolute (starts with
/),path.join('/safe/dir', '/etc/passwd')returns'/etc/passwd'βdirNameis silently discarded. - 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
- 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 amodel.jsonwhoseweightsManifestis attacker-controlled. - The process uses
@tensorflow/tfjs-node(or@tensorflow/tfjs-node-gpu). - 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
- Attacker authors a
model.jsonwhoseweightsManifest[0].paths[0]contains either an absolute path (/etc/passwd,/var/run/secrets/kubernetes.io/serviceaccount/token) or a relative traversal (../../../../../etc/hostname). - Attacker hosts the
model.jsonsomewhere 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. - Victim service calls
tf.loadGraphModel('file:///path/to/uploaded/model.json')(ortf.loadLayersModel, ortf.io.fileSystem(...)). - The library follows the manifest path;
loadWeightsreads the attacker's chosen file and stuffs its bytes into the resultingModelArtifacts. - Attacker recovers the bytes by:
- reading them from
artifacts.weightDataif 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.
- reading them from
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
loadWeightsis reached via the two-argumentloadBinaryModelentry 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_converterships an equivalent unchecked join inread_weights.py:65-74. Same root-cause class, different language/process, distinct CI/CD impact (steals$GITHUB_TOKENetc.).
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 |